diff --git a/account_reconcile_oca/README.rst b/account_reconcile_oca/README.rst new file mode 100644 index 00000000..9ec60fd9 --- /dev/null +++ b/account_reconcile_oca/README.rst @@ -0,0 +1,96 @@ +===================== +Account Reconcile Oca +===================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--reconcile-lightgray.png?logo=github + :target: https://github.com/OCA/account-reconcile/tree/16.0/account_reconcile_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-16-0/account-reconcile-16-0-account_reconcile_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/98/16.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon allows to reconcile bank statements and account marked as `reconcile`. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Bank reconcile +~~~~~~~~~~~~~~ + +Access `Invoicing / Dashboard` with a user with Full Acounting capabilities. +Select reconcile on the journal of your choice. + +Account reconcile +~~~~~~~~~~~~~~~~~ + +Access `Invoicing / Accounting / Actions / Reconcile` +All the possible reconcile options will show and you will be able to reconcile properly. +You can access the same widget from accounts and Partners. + +Known issues / Roadmap +====================== + +The following bugs are already detected: + +* Creation of activities on the chatter do show automatically + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* CreuBlanca + +Contributors +~~~~~~~~~~~~ + +* Enric Tobella + +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_oca/__init__.py b/account_reconcile_oca/__init__.py new file mode 100644 index 00000000..cc6b6354 --- /dev/null +++ b/account_reconcile_oca/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/account_reconcile_oca/__manifest__.py b/account_reconcile_oca/__manifest__.py new file mode 100644 index 00000000..b5f8beb4 --- /dev/null +++ b/account_reconcile_oca/__manifest__.py @@ -0,0 +1,45 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Account Reconcile Oca", + "summary": """ + Reconcile addons for Odoo CE accounting""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "CreuBlanca,Odoo Community Association (OCA)", + "maintainers": ["etobella"], + "website": "https://github.com/OCA/account-reconcile", + "depends": [ + "account", + "base_sparse_field", + ], + "data": [ + "security/ir.model.access.csv", + "views/account_account_reconcile.xml", + "views/account_bank_statement_line.xml", + "views/account_move_line.xml", + "views/account_journal.xml", + "views/account_move.xml", + "views/account_account.xml", + ], + "demo": ["demo/demo.xml"], + "post_init_hook": "post_init_hook", + "assets": { + "web.assets_backend": [ + "account_reconcile_oca/static/src/js/reconcile_manual_view.esm.js", + "account_reconcile_oca/static/src/js/reconcile_data_widget.esm.js", + "account_reconcile_oca/static/src/js/reconcile_chatter_field.esm.js", + "account_reconcile_oca/static/src/js/selection_badge_uncheck.esm.js", + "account_reconcile_oca/static/src/js/reconcile_move_line_view.esm.js", + "account_reconcile_oca/static/src/js/reconcile_move_line_widget.esm.js", + "account_reconcile_oca/static/src/js/reconcile_kanban_record.esm.js", + "account_reconcile_oca/static/src/js/reconcile_renderer.esm.js", + "account_reconcile_oca/static/src/js/reconcile_controller.esm.js", + "account_reconcile_oca/static/src/js/reconcile_view.esm.js", + "account_reconcile_oca/static/src/js/reconcile_form_view.esm.js", + "account_reconcile_oca/static/src/xml/reconcile.xml", + "account_reconcile_oca/static/src/scss/reconcile.scss", + ], + }, +} diff --git a/account_reconcile_oca/demo/demo.xml b/account_reconcile_oca/demo/demo.xml new file mode 100644 index 00000000..a27a51e8 --- /dev/null +++ b/account_reconcile_oca/demo/demo.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/account_reconcile_oca/hooks.py b/account_reconcile_oca/hooks.py new file mode 100644 index 00000000..dd1094eb --- /dev/null +++ b/account_reconcile_oca/hooks.py @@ -0,0 +1,8 @@ +def post_init_hook(cr, registry): + cr.execute( + """ + UPDATE account_bank_statement_line + SET reconcile_mode = 'edit' + WHERE is_reconciled + """ + ) diff --git a/account_reconcile_oca/models/__init__.py b/account_reconcile_oca/models/__init__.py new file mode 100644 index 00000000..8102733a --- /dev/null +++ b/account_reconcile_oca/models/__init__.py @@ -0,0 +1,4 @@ +from . import account_reconcile_abstract +from . import account_journal +from . import account_bank_statement_line +from . import account_account_reconcile diff --git a/account_reconcile_oca/models/account_account_reconcile.py b/account_reconcile_oca/models/account_account_reconcile.py new file mode 100644 index 00000000..6a33d786 --- /dev/null +++ b/account_reconcile_oca/models/account_account_reconcile.py @@ -0,0 +1,171 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class CharId(fields.Id): + type = "string" + column_type = ("varchar", fields.pg_varchar()) + + +class AccountAccountReconcile(models.Model): + _name = "account.account.reconcile" + _description = "Account Account Reconcile" + _inherit = "account.reconcile.abstract" + _auto = False + + reconcile_data_info = fields.Serialized(inverse="_inverse_reconcile_data_info") + + partner_id = fields.Many2one("res.partner") + account_id = fields.Many2one("account.account") + name = fields.Char() + is_reconciled = fields.Boolean() + currency_id = fields.Many2one("res.currency") + + @property + def _table_query(self): + return "%s %s %s %s %s" % ( + self._select(), + self._from(), + self._where(), + self._groupby(), + self._having(), + ) + + def _select(self): + return """ + SELECT + CAST( + ( + coalesce(aml.partner_id, 0) + a.id + )*( + COALESCE(aml.partner_id, 0)+a.id - 1 + )/2 + COALESCE(aml.partner_id, 0) AS INTEGER + ) as id, + MAX(a.name) as name, + aml.partner_id, + a.id as account_id, + FALSE as is_reconciled, + aml.currency_id as currency_id, + a.company_id + """ + + def _from(self): + return """ + FROM + account_account a + INNER JOIN account_move_line aml ON aml.account_id = a.id + INNER JOIN account_move am ON am.id = aml.move_id + """ + + def _where(self): + return """ + WHERE a.reconcile + AND am.state = 'posted' + AND aml.amount_residual != 0 + """ + + def _groupby(self): + return """ + GROUP BY + a.id, aml.partner_id, aml.currency_id, a.company_id + """ + + def _having(self): + return """ + HAVING + SUM(aml.debit) > 0 + AND SUM(aml.credit) > 0 + """ + + def _compute_reconcile_data_info(self): + data_obj = self.env["account.account.reconcile.data"] + for record in self: + data_record = data_obj.search( + [("user_id", "=", self.env.user.id), ("reconcile_id", "=", record.id)] + ) + if data_record: + record.reconcile_data_info = data_record.data + else: + record.reconcile_data_info = {"data": [], "counterparts": []} + + def _inverse_reconcile_data_info(self): + data_obj = self.env["account.account.reconcile.data"] + for record in self: + data_record = data_obj.search( + [("user_id", "=", self.env.user.id), ("reconcile_id", "=", record.id)] + ) + if data_record: + data_record.data = record.reconcile_data_info + else: + data_obj.create( + { + "reconcile_id": record.id, + "user_id": self.env.user.id, + "data": record.reconcile_data_info, + } + ) + + @api.onchange("add_account_move_line_id") + def _onchange_add_account_move_line(self): + if self.add_account_move_line_id: + data = self.reconcile_data_info + if self.add_account_move_line_id.id not in data["counterparts"]: + data["counterparts"].append(self.add_account_move_line_id.id) + else: + del data["counterparts"][ + data["counterparts"].index(self.add_account_move_line_id.id) + ] + self.reconcile_data_info = self._recompute_data(data) + self.add_account_move_line_id = False + + @api.onchange("manual_reference", "manual_delete") + def _onchange_manual_reconcile_reference(self): + self.ensure_one() + data = self.reconcile_data_info + counterparts = [] + for line in data["data"]: + if line["reference"] == self.manual_reference: + if self.manual_delete: + continue + counterparts.append(line["id"]) + data["counterparts"] = counterparts + self.reconcile_data_info = self._recompute_data(data) + self.manual_delete = False + self.manual_reference = False + + def _recompute_data(self, data): + new_data = {"data": [], "counterparts": data["counterparts"]} + counterparts = data["counterparts"] + amount = 0.0 + for line_id in counterparts: + line = self._get_reconcile_line( + self.env["account.move.line"].browse(line_id), "other", True, amount + ) + new_data["data"].append(line) + amount += line["amount"] + return new_data + + def clean_reconcile(self): + self.ensure_one() + self.reconcile_data_info = {"data": [], "counterparts": []} + + def reconcile(self): + lines = self.env["account.move.line"].browse( + self.reconcile_data_info["counterparts"] + ) + lines.reconcile() + data_record = self.env["account.account.reconcile.data"].search( + [("user_id", "=", self.env.user.id), ("reconcile_id", "=", self.id)] + ) + data_record.unlink() + + +class AccountAccountReconcileData(models.TransientModel): + _name = "account.account.reconcile.data" + _description = "Reconcile data model to store user info" + + user_id = fields.Many2one("res.users", required=True) + reconcile_id = fields.Integer(required=True) + data = fields.Serialized() diff --git a/account_reconcile_oca/models/account_bank_statement_line.py b/account_reconcile_oca/models/account_bank_statement_line.py new file mode 100644 index 00000000..c7c90e4c --- /dev/null +++ b/account_reconcile_oca/models/account_bank_statement_line.py @@ -0,0 +1,574 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import Command, _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_is_zero + + +class AccountBankStatementLine(models.Model): + _name = "account.bank.statement.line" + _inherit = ["account.bank.statement.line", "account.reconcile.abstract"] + + reconcile_data_info = fields.Serialized(inverse="_inverse_reconcile_data_info") + reconcile_mode = fields.Selection( + selection=lambda self: self.env["account.journal"] + ._fields["reconcile_mode"] + .selection + ) + company_id = fields.Many2one(related="journal_id.company_id") + reconcile_data = fields.Serialized() + manual_line_id = fields.Many2one( + "account.move.line", + store=False, + default=False, + prefetch=False, + ) + manual_kind = fields.Char( + store=False, + default=False, + prefetch=False, + ) + manual_account_id = fields.Many2one( + "account.account", + check_company=True, + store=False, + default=False, + prefetch=False, + ) + manual_partner_id = fields.Many2one( + "res.partner", + check_company=True, + store=False, + default=False, + prefetch=False, + ) + manual_model_id = fields.Many2one( + "account.reconcile.model", + check_company=True, + store=False, + default=False, + prefetch=False, + domain=[("rule_type", "=", "writeoff_button")], + ) + manual_delete = fields.Boolean( + store=False, + default=False, + prefetch=False, + ) + manual_name = fields.Char(store=False, default=False, prefetch=False) + manual_amount = fields.Monetary(store=False, default=False, prefetch=False) + can_reconcile = fields.Boolean(sparse="reconcile_data_info") + + def save(self): + return {"type": "ir.actions.act_window_close"} + + @api.model + def action_new_line(self): + action = self.env["ir.actions.act_window"]._for_xml_id( + "account_reconcile_oca.action_bank_statement_line_create" + ) + action["context"] = self.env.context + return action + + @api.onchange("manual_model_id") + def _onchange_manual_model_id(self): + if self.manual_model_id: + data = [] + for line in self.reconcile_data_info.get("data", []): + if line.get("kind") != "suspense": + data.append(line) + self.reconcile_data_info = self._recompute_suspense_line( + *self._reconcile_data_by_model( + data, + self.manual_model_id, + self.reconcile_data_info["reconcile_auxiliary_id"], + ) + ) + else: + # Refreshing data + self.reconcile_data_info = self.browse( + self.id.origin + )._default_reconcile_data() + self.can_reconcile = self.reconcile_data_info.get("can_reconcile", False) + + @api.onchange("add_account_move_line_id") + def _onchange_add_account_move_line_id(self): + if self.add_account_move_line_id: + data = self.reconcile_data_info["data"] + new_data = [] + is_new_line = True + pending_amount = 0.0 + for line in data: + if line["kind"] != "suspense": + pending_amount += line["amount"] + if line.get("counterpart_line_id") == self.add_account_move_line_id.id: + is_new_line = False + else: + new_data.append(line) + if is_new_line: + new_data.append( + self._get_reconcile_line( + self.add_account_move_line_id, "other", True, pending_amount + ) + ) + self.reconcile_data_info = self._recompute_suspense_line( + new_data, self.reconcile_data_info["reconcile_auxiliary_id"] + ) + self.can_reconcile = self.reconcile_data_info.get("can_reconcile", False) + self.add_account_move_line_id = False + + def _recompute_suspense_line(self, data, reconcile_auxiliary_id): + can_reconcile = True + total_amount = 0 + new_data = [] + suspense_line = False + counterparts = [] + for line in data: + if line.get("counterpart_line_id"): + counterparts.append(line["counterpart_line_id"]) + if ( + line["account_id"][0] == self.journal_id.suspense_account_id.id + or not line["account_id"][0] + ) and line["kind"] != "suspense": + can_reconcile = False + if line["kind"] != "suspense": + new_data.append(line) + total_amount += line["amount"] + else: + suspense_line = line + if not float_is_zero( + total_amount, precision_digits=self.currency_id.decimal_places + ): + can_reconcile = False + if suspense_line: + suspense_line.update( + { + "amount": -total_amount, + "credit": total_amount if total_amount > 0 else 0.0, + "debit": -total_amount if total_amount < 0 else 0.0, + } + ) + else: + suspense_line = { + "reference": "reconcile_auxiliary;%s" % reconcile_auxiliary_id, + "id": False, + "account_id": self.journal_id.suspense_account_id.name_get()[0], + "partner_id": self.partner_id + and self.partner_id.name_get()[0] + or False, + "date": fields.Date.to_string(self.date), + "name": self.name, + "amount": -total_amount, + "credit": total_amount if total_amount > 0 else 0.0, + "debit": -total_amount if total_amount < 0 else 0.0, + "kind": "suspense", + "currency_id": self.currency_id.id, + } + reconcile_auxiliary_id += 1 + new_data.append(suspense_line) + return { + "data": new_data, + "counterparts": counterparts, + "reconcile_auxiliary_id": reconcile_auxiliary_id, + "can_reconcile": can_reconcile, + } + + def _check_line_changed(self, line): + return ( + not float_is_zero( + self.manual_amount - line["amount"], + precision_digits=self.currency_id.decimal_places, + ) + or self.manual_account_id.id != line["account_id"][0] + or self.manual_name != line["name"] + or ( + self.manual_partner_id and self.manual_partner_id.name_get()[0] or False + ) + != line.get("partner_id") + ) + + @api.onchange("manual_reference", "manual_delete") + def _onchange_manual_reconcile_reference(self): + self.ensure_one() + data = self.reconcile_data_info.get("data", []) + new_data = [] + for line in data: + if line["reference"] == self.manual_reference: + if self.manual_delete: + self.update( + { + "manual_delete": False, + "manual_reference": False, + "manual_account_id": False, + "manual_amount": False, + "manual_name": False, + "manual_partner_id": False, + "manual_line_id": False, + "manual_kind": False, + } + ) + continue + else: + self.manual_account_id = line["account_id"][0] + self.manual_amount = line["amount"] + self.manual_name = line["name"] + self.manual_partner_id = ( + line.get("partner_id") and line["partner_id"][0] + ) + self.manual_line_id = line["id"] + self.manual_kind = line["kind"] + new_data.append(line) + self.reconcile_data_info = self._recompute_suspense_line( + new_data, self.reconcile_data_info["reconcile_auxiliary_id"] + ) + self.can_reconcile = self.reconcile_data_info.get("can_reconcile", False) + + @api.onchange( + "manual_account_id", + "manual_partner_id", + "manual_name", + "manual_amount", + ) + def _onchange_manual_reconcile_vals(self): + self.ensure_one() + data = self.reconcile_data_info.get("data", []) + new_data = [] + for line in data: + if line["reference"] == self.manual_reference: + if self._check_line_changed(line): + line.update( + { + "name": self.manual_name, + "partner_id": self.manual_partner_id + and self.manual_partner_id.name_get()[0] + or False, + "account_id": self.manual_account_id.name_get()[0] + if self.manual_account_id + else [False, _("Undefined")], + "amount": self.manual_amount, + "credit": -self.manual_amount + if self.manual_amount < 0 + else 0.0, + "debit": self.manual_amount + if self.manual_amount > 0 + else 0.0, + "kind": line["kind"] + if line["kind"] != "suspense" + else "other", + } + ) + if line["kind"] == "liquidity": + self._update_move_partner() + new_data.append(line) + self.reconcile_data_info = self._recompute_suspense_line( + new_data, self.reconcile_data_info["reconcile_auxiliary_id"] + ) + self.can_reconcile = self.reconcile_data_info.get("can_reconcile", False) + + def _update_move_partner(self): + if self.partner_id == self.manual_partner_id: + return + self.partner_id = self.manual_partner_id + + @api.depends("reconcile_data") + def _compute_reconcile_data_info(self): + for record in self: + if record.reconcile_data: + record.reconcile_data_info = record.reconcile_data + else: + record.reconcile_data_info = record._default_reconcile_data() + record.can_reconcile = record.reconcile_data_info.get( + "can_reconcile", False + ) + + def action_show_move(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "account.action_move_journal_line" + ) + action.update( + {"res_id": self.move_id.id, "views": [[False, "form"]], "view_mode": "form"} + ) + return action + + def _inverse_reconcile_data_info(self): + for record in self: + record.reconcile_data = record.reconcile_data_info + + def _reconcile_data_by_model(self, data, reconcile_model, reconcile_auxiliary_id): + new_data = [] + liquidity_amount = 0.0 + for line_data in data: + if line_data["kind"] == "suspense": + continue + new_data.append(line_data) + liquidity_amount += line_data["amount"] + for line in reconcile_model._apply_lines_for_bank_widget( + -liquidity_amount, self._retrieve_partner(), self + ): + amount = line["amount_currency"] + new_line = { + "reference": "reconcile_auxiliary;%s" % reconcile_auxiliary_id, + "id": False, + "amount": amount, + "debit": amount if amount > 0 else 0.0, + "credit": -amount if amount < 0 else 0.0, + "kind": "other", + "account_id": self.env["account.account"] + .browse(line["account_id"]) + .name_get()[0], + "date": fields.Date.to_string(self.date), + "name": line.get("name"), + "currency_id": line.get("currency_id"), + } + reconcile_auxiliary_id += 1 + if line.get("partner_id"): + new_line["partner_id"] = ( + self.env["res.partner"].browse(line["partner_id"]).name_get()[0] + ) + new_data.append(new_line) + return new_data, reconcile_auxiliary_id + + def _compute_exchange_rate(self, data): + reconcile_auxiliary_id = 1 + if not self.foreign_currency_id or self.is_reconciled: + return reconcile_auxiliary_id + currency = self.journal_id.currency_id or self.company_id.currency_id + currency_amount = self.foreign_currency_id._convert( + self.amount_currency, currency, self.company_id, self.date + ) + amount = sum(d["amount"] for d in data) - currency_amount + if not currency.is_zero(amount): + account = self.company_id.expense_currency_exchange_account_id + if amount > 0: + account = self.company_id.income_currency_exchange_account_id + data.append( + { + "reference": "reconcile_auxiliary;%s" % reconcile_auxiliary_id, + "id": False, + "account_id": account.name_get()[0], + "partner_id": False, + "date": fields.Date.to_string(self.date), + "name": self.name, + "amount": -amount, + "credit": amount if amount > 0 else 0.0, + "debit": -amount if amount < 0 else 0.0, + "kind": "other", + "currency_id": self.currency_id.id, + } + ) + reconcile_auxiliary_id += 1 + return reconcile_auxiliary_id + + def _default_reconcile_data(self): + liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() + data = [self._get_reconcile_line(line, "liquidity") for line in liquidity_lines] + reconcile_auxiliary_id = self._compute_exchange_rate(data) + res = ( + self.env["account.reconcile.model"] + .search([("rule_type", "in", ["invoice_matching", "writeoff_suggestion"])]) + ._apply_rules(self, self._retrieve_partner()) + ) + if res and res.get("status", "") == "write_off": + return self._recompute_suspense_line( + *self._reconcile_data_by_model( + data, res["model"], reconcile_auxiliary_id + ) + ) + elif res and res.get("amls"): + amount = self.amount + for line in res.get("amls", []): + line_data = self._get_reconcile_line( + line, "other", is_counterpart=True, max_amount=amount + ) + amount -= line_data.get("amount") + data.append(line_data) + return self._recompute_suspense_line(data, reconcile_auxiliary_id) + return self._recompute_suspense_line( + data + [self._get_reconcile_line(line, "other") for line in other_lines], + reconcile_auxiliary_id, + ) + + def clean_reconcile(self): + self.reconcile_data_info = self._default_reconcile_data() + self.can_reconcile = self.reconcile_data_info.get("can_reconcile", False) + + def reconcile_bank_line(self): + self.ensure_one() + self.reconcile_mode = self.journal_id.reconcile_mode + return getattr(self, "_reconcile_bank_line_%s" % self.reconcile_mode)( + self.reconcile_data_info["data"] + ) + + def _reconcile_bank_line_edit(self, data): + _liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() + lines_to_remove = [(2, line.id) for line in suspense_lines + other_lines] + + # Cleanup previous lines. + move = self.move_id + container = {"records": move, "self": move} + to_reconcile = [] + with move._check_balanced(container): + move.with_context( + skip_account_move_synchronization=True, force_delete=True + ).write( + { + "line_ids": lines_to_remove, + } + ) + for line_vals in data: + if line_vals["kind"] == "liquidity": + continue + line = ( + self.env["account.move.line"] + .with_context(check_move_validity=False) + .create(self._reconcile_move_line_vals(line_vals)) + ) + if line_vals.get("counterpart_line_id"): + to_reconcile.append( + self.env["account.move.line"].browse( + line_vals.get("counterpart_line_id") + ) + + line + ) + for reconcile_items in to_reconcile: + reconcile_items.reconcile() + + def _reconcile_bank_line_keep_move_vals(self): + return { + "journal_id": self.journal_id.id, + } + + def _reconcile_bank_line_keep(self, data): + move = ( + self.env["account.move"] + .with_context(skip_invoice_sync=True) + .create(self._reconcile_bank_line_keep_move_vals()) + ) + _liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() + container = {"records": move, "self": move} + to_reconcile = defaultdict(lambda: self.env["account.move.line"]) + with move._check_balanced(container): + for line in suspense_lines | other_lines: + to_reconcile[line.account_id.id] |= line + line_data = line.with_context( + active_test=False, + include_business_fields=True, + ).copy_data({"move_id": move.id})[0] + to_reconcile[line.account_id.id] |= ( + self.env["account.move.line"] + .with_context(check_move_validity=False, skip_invoice_sync=True) + .create(line_data) + ) + move.write( + { + "line_ids": [ + Command.update( + line.id, + { + "balance": -line.balance, + "amount_currency": -line.amount_currency, + }, + ) + for line in move.line_ids + if line.move_id.move_type == "entry" + or line.display_type == "cogs" + ] + } + ) + for line_vals in data: + if line_vals["kind"] == "liquidity": + continue + if line_vals["kind"] == "suspense": + raise UserError(_("No supense lines are allowed when reconciling")) + line = ( + self.env["account.move.line"] + .with_context(check_move_validity=False, skip_invoice_sync=True) + .create(self._reconcile_move_line_vals(line_vals, move.id)) + ) + if line_vals.get("counterpart_line_id") and line.account_id.reconcile: + to_reconcile[line.account_id.id] |= ( + self.env["account.move.line"].browse( + line_vals.get("counterpart_line_id") + ) + | line + ) + move.invalidate_recordset() + move._post() + for _account, lines in to_reconcile.items(): + lines.reconcile() + + def unreconcile_bank_line(self): + self.ensure_one() + return getattr(self, "_unreconcile_bank_line_%s" % self.reconcile_mode)( + self.reconcile_data_info["data"] + ) + + def _unreconcile_bank_line_edit(self, data): + self.move_id.button_draft() + self.move_id.line_ids.unlink() + self.move_id.write( + { + "line_ids": [ + (0, 0, line_vals) + for line_vals in self._prepare_move_line_default_vals() + ] + } + ) + self.move_id.action_post() + + def _unreconcile_bank_line_keep(self, data): + raise UserError(_("Keep suspense move lines mode cannot be unreconciled")) + + def _reconcile_move_line_vals(self, line, move_id=False): + return { + "move_id": move_id or self.move_id.id, + "account_id": line["account_id"][0], + "partner_id": line.get("partner_id") and line["partner_id"][0], + "credit": line["credit"], + "debit": line["debit"], + } + + @api.model_create_multi + def create(self, mvals): + result = super().create(mvals) + models = self.env["account.reconcile.model"].search( + [ + ("rule_type", "in", ["invoice_matching", "writeoff_suggestion"]), + ("auto_reconcile", "=", True), + ] + ) + for record in result: + res = models._apply_rules(record, record._retrieve_partner()) + if not res: + continue + liquidity_lines, suspense_lines, other_lines = record._seek_for_lines() + data = [ + record._get_reconcile_line(line, "liquidity") + for line in liquidity_lines + ] + reconcile_auxiliary_id = record._compute_exchange_rate(data) + if res.get("status", "") == "write_off": + data = record._recompute_suspense_line( + *record._reconcile_data_by_model( + data, res["model"], reconcile_auxiliary_id + ) + ) + elif res.get("amls"): + amount = self.amount + for line in res.get("amls", []): + line_data = record._get_reconcile_line( + line, "other", is_counterpart=True, max_amount=amount + ) + amount -= line_data.get("amount") + data.append(line_data) + data = record._recompute_suspense_line(data, reconcile_auxiliary_id) + if not data.get("can_reconcile"): + continue + getattr( + record, "_reconcile_bank_line_%s" % record.journal_id.reconcile_mode + )(data["data"]) + return result diff --git a/account_reconcile_oca/models/account_journal.py b/account_reconcile_oca/models/account_journal.py new file mode 100644 index 00000000..56e3bf34 --- /dev/null +++ b/account_reconcile_oca/models/account_journal.py @@ -0,0 +1,28 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + reconcile_mode = fields.Selection( + [("edit", "Edit Move"), ("keep", "Keep Suspense Accounts")], + default="edit", + required=True, + ) + + def action_open_reconcile_to_check(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "account_reconcile_oca.action_bank_statement_line_reconcile" + ) + action["domain"] = [("id", "=", self.to_check_ids().ids)] + return action + + def get_rainbowman_message(self): + self.ensure_one() + if self.get_journal_dashboard_datas()["number_to_reconcile"] > 0: + return False + return _("Well done! Everything has been reconciled") diff --git a/account_reconcile_oca/models/account_reconcile_abstract.py b/account_reconcile_oca/models/account_reconcile_abstract.py new file mode 100644 index 00000000..659ae5c8 --- /dev/null +++ b/account_reconcile_oca/models/account_reconcile_abstract.py @@ -0,0 +1,64 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models +from odoo.tools import float_is_zero + + +class AccountReconcileAbstract(models.AbstractModel): + _name = "account.reconcile.abstract" + _description = "Account Reconcile Abstract" + + reconcile_data_info = fields.Serialized( + compute="_compute_reconcile_data_info", + prefetch=False, + ) + company_id = fields.Many2one("res.company") + add_account_move_line_id = fields.Many2one( + "account.move.line", + check_company=True, + store=False, + default=False, + prefetch=False, + ) + manual_reference = fields.Char(store=False, default=False, prefetch=False) + manual_delete = fields.Boolean( + store=False, + default=False, + prefetch=False, + ) + + def _get_reconcile_line(self, line, kind, is_counterpart=False, max_amount=False): + original_amount = amount = line.debit - line.credit + if is_counterpart: + original_amount = amount = ( + line.amount_residual_currency or line.amount_residual + ) + if max_amount: + if amount > max_amount > 0: + amount = max_amount + if amount < max_amount < 0: + amount = max_amount + if is_counterpart: + amount = -amount + original_amount = -original_amount + vals = { + "reference": "account.move.line;%s" % line.id, + "id": line.id, + "account_id": line.account_id.name_get()[0], + "partner_id": line.partner_id and line.partner_id.name_get()[0] or False, + "date": fields.Date.to_string(line.date), + "name": line.name, + "debit": amount if amount > 0 else 0.0, + "credit": -amount if amount < 0 else 0.0, + "amount": amount, + "currency_id": line.currency_id.id, + "kind": kind, + } + if not float_is_zero( + amount - original_amount, precision_digits=line.currency_id.decimal_places + ): + vals["original_amount"] = abs(original_amount) + if is_counterpart: + vals["counterpart_line_id"] = line.id + return vals diff --git a/account_reconcile_oca/readme/CONTRIBUTORS.rst b/account_reconcile_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..85004765 --- /dev/null +++ b/account_reconcile_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Enric Tobella diff --git a/account_reconcile_oca/readme/DESCRIPTION.rst b/account_reconcile_oca/readme/DESCRIPTION.rst new file mode 100644 index 00000000..e50452fb --- /dev/null +++ b/account_reconcile_oca/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This addon allows to reconcile bank statements and account marked as `reconcile`. diff --git a/account_reconcile_oca/readme/ROADMAP.rst b/account_reconcile_oca/readme/ROADMAP.rst new file mode 100644 index 00000000..24e9bc53 --- /dev/null +++ b/account_reconcile_oca/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +The following bugs are already detected: + +* Creation of activities on the chatter do show automatically diff --git a/account_reconcile_oca/readme/USAGE.rst b/account_reconcile_oca/readme/USAGE.rst new file mode 100644 index 00000000..fc080e09 --- /dev/null +++ b/account_reconcile_oca/readme/USAGE.rst @@ -0,0 +1,12 @@ +Bank reconcile +~~~~~~~~~~~~~~ + +Access `Invoicing / Dashboard` with a user with Full Acounting capabilities. +Select reconcile on the journal of your choice. + +Account reconcile +~~~~~~~~~~~~~~~~~ + +Access `Invoicing / Accounting / Actions / Reconcile` +All the possible reconcile options will show and you will be able to reconcile properly. +You can access the same widget from accounts and Partners. diff --git a/account_reconcile_oca/security/ir.model.access.csv b/account_reconcile_oca/security/ir.model.access.csv new file mode 100644 index 00000000..d73fa145 --- /dev/null +++ b/account_reconcile_oca/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_account_reconcile,account.account.reconcile,model_account_account_reconcile,account.group_account_user,1,1,0,0 +access_account_account_reconcile_data,account.account.reconcile,model_account_account_reconcile_data,account.group_account_user,1,1,1,1 diff --git a/account_reconcile_oca/static/description/icon.png b/account_reconcile_oca/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/account_reconcile_oca/static/description/icon.png differ diff --git a/account_reconcile_oca/static/description/index.html b/account_reconcile_oca/static/description/index.html new file mode 100644 index 00000000..ca0693c1 --- /dev/null +++ b/account_reconcile_oca/static/description/index.html @@ -0,0 +1,446 @@ + + + + + + +Account Reconcile Oca + + + +
+

Account Reconcile Oca

+ + +

Beta License: AGPL-3 OCA/account-reconcile Translate me on Weblate Try me on Runbot

+

This addon allows to reconcile bank statements and account marked as reconcile.

+

Table of contents

+ +
+

Usage

+
+

Bank reconcile

+

Access Invoicing / Dashboard with a user with Full Acounting capabilities. +Select reconcile on the journal of your choice.

+
+
+

Account reconcile

+

Access Invoicing / Accounting / Actions / Reconcile +All the possible reconcile options will show and you will be able to reconcile properly. +You can access the same widget from accounts and Partners.

+
+
+
+

Known issues / Roadmap

+

The following bugs are already detected:

+
    +
  • Creation of activities on the chatter do show automatically
  • +
+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • CreuBlanca
  • +
+
+
+

Contributors

+
    +
  • Enric Tobella
  • +
+
+
+

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_oca/static/src/js/reconcile_chatter_field.esm.js b/account_reconcile_oca/static/src/js/reconcile_chatter_field.esm.js new file mode 100644 index 00000000..c9e6f4e0 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_chatter_field.esm.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import {registry} from "@web/core/registry"; +import {ChatterContainer} from "@mail/components/chatter_container/chatter_container"; + +const {Component} = owl; + +export class AccountReconcileChatterWidget extends Component {} +AccountReconcileChatterWidget.template = + "account_reconcile_oca.AccountReconcileChatterWidget"; +AccountReconcileChatterWidget.components = {...Component.components, ChatterContainer}; + +registry + .category("fields") + .add("account_reconcile_oca_chatter", AccountReconcileChatterWidget); diff --git a/account_reconcile_oca/static/src/js/reconcile_controller.esm.js b/account_reconcile_oca/static/src/js/reconcile_controller.esm.js new file mode 100644 index 00000000..90756cfc --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_controller.esm.js @@ -0,0 +1,99 @@ +/** @odoo-module */ +const {useState, useSubEnv} = owl; +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {View} from "@web/views/view"; +import {useService} from "@web/core/utils/hooks"; + +export class ReconcileController extends KanbanController { + async setup() { + super.setup(); + this.state = useState({ + selectedRecordId: null, + }); + useSubEnv({ + parentController: this, + exposeController: this.exposeController.bind(this), + }); + this.effect = useService("effect"); + this.orm = useService("orm"); + this.action = useService("action"); + this.activeActions = this.props.archInfo.activeActions; + this.model.addEventListener("update", () => this.selectRecord(), {once: true}); + } + exposeController(controller) { + this.form_controller = controller; + } + async onClickNewButton() { + const action = await this.orm.call(this.props.resModel, "action_new_line", [], { + context: this.props.context, + }); + this.action.doAction(action, { + onClose: async () => { + await this.model.root.load(); + this.render(true); + }, + }); + } + async setRainbowMan(message) { + this.effect.add({ + message, + type: "rainbow_man", + }); + } + get viewReconcileInfo() { + return { + resId: this.state.selectedRecordId, + type: "form", + context: { + ...(this.props.context || {}), + form_view_ref: this.props.context.view_ref, + }, + display: {controlPanel: false}, + mode: this.props.mode || "edit", + resModel: this.props.resModel, + }; + } + async selectRecord(record) { + var resId = undefined; + if (record === undefined) { + var records = this.model.root.records.filter( + (modelRecord) => + !modelRecord.data.is_reconciled || modelRecord.data.to_check + ); + if (records.length === 0) { + records = this.model.root.records; + if (records.length === 0) { + this.state.selectedRecordId = false; + return; + } + } + resId = records[0].resId; + } else { + resId = record.resId; + } + if (this.state.selectedRecordId && this.state.selectedRecordId !== resId) { + if (this.form_controller.model.root.isDirty) { + await this.form_controller.model.root.save({ + noReload: true, + stayInEdition: true, + useSaveErrorDialog: true, + }); + await this.model.root.load(); + await this.render(true); + } + } + if (!this.state.selectedRecordId || this.state.selectedRecordId !== resId) { + this.state.selectedRecordId = resId; + } + } + async openRecord(record) { + this.selectRecord(record); + } +} +ReconcileController.components = { + ...ReconcileController.components, + View, +}; + +ReconcileController.template = "account_reconcile_oca.ReconcileController"; +ReconcileController.defaultProps = {}; diff --git a/account_reconcile_oca/static/src/js/reconcile_data_widget.esm.js b/account_reconcile_oca/static/src/js/reconcile_data_widget.esm.js new file mode 100644 index 00000000..87f63fa7 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_data_widget.esm.js @@ -0,0 +1,65 @@ +/** @odoo-module **/ + +import fieldUtils from "web.field_utils"; +import session from "web.session"; +import {registry} from "@web/core/registry"; + +const {Component} = owl; + +export class AccountReconcileDataWidget extends Component { + getReconcileLines() { + var data = this.props.record.data[this.props.name].data; + for (var line in data) { + data[line].amount_format = fieldUtils.format.monetary( + data[line].amount, + undefined, + { + currency: session.get_currency(data[line].currency_id), + } + ); + data[line].debit_format = fieldUtils.format.monetary( + data[line].debit, + undefined, + { + currency: session.get_currency(data[line].currency_id), + } + ); + data[line].credit_format = fieldUtils.format.monetary( + data[line].credit, + undefined, + { + currency: session.get_currency(data[line].currency_id), + } + ); + if (data[line].original_amount) { + data[line].original_amount_format = fieldUtils.format.monetary( + data[line].original_amount, + undefined, + { + currency: session.get_currency(data[line].currency_id), + } + ); + } + data[line].date_format = fieldUtils.format.date( + fieldUtils.parse.date(data[line].date, undefined, {isUTC: true}) + ); + } + return data; + } + onTrashLine(ev, line) { + this.props.record.update({ + manual_reference: line.reference, + manual_delete: true, + }); + } + selectReconcileLine(ev, line) { + this.props.record.update({ + manual_reference: line.reference, + }); + } +} +AccountReconcileDataWidget.template = "account_reconcile_oca.ReconcileDataWidget"; + +registry + .category("fields") + .add("account_reconcile_oca_data", AccountReconcileDataWidget); diff --git a/account_reconcile_oca/static/src/js/reconcile_form_view.esm.js b/account_reconcile_oca/static/src/js/reconcile_form_view.esm.js new file mode 100644 index 00000000..7a49f3f9 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_form_view.esm.js @@ -0,0 +1,51 @@ +/** @odoo-module */ + +import {FormController} from "@web/views/form/form_controller"; +import {formView} from "@web/views/form/form_view"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {useViewButtons} from "@web/views/view_button/view_button_hook"; +const {useRef} = owl; + +export class ReconcileFormController extends FormController { + setup() { + super.setup(...arguments); + this.env.exposeController(this); + this.orm = useService("orm"); + const rootRef = useRef("root"); + useViewButtons(this.model, rootRef, { + reload: this.reloadFormController.bind(this), + beforeExecuteAction: this.beforeExecuteActionButton.bind(this), + afterExecuteAction: this.afterExecuteActionButton.bind(this), + }); + } + async reloadFormController() { + var is_reconciled = this.model.root.data.is_reconciled; + await this.model.root.load(); + if (!is_reconciled && this.model.root.data.is_reconciled) { + // This only happens when we press the reconcile button + if (this.env.parentController) { + // Showing rainbow man + const message = await this.orm.call( + "account.journal", + "get_rainbowman_message", + [[this.model.root.data.journal_id[0]]] + ); + if (message) { + this.env.parentController.setRainbowMan(message); + } + // Refreshing + await this.env.parentController.model.root.load(); + await this.env.parentController.render(true); + this.env.parentController.selectRecord(); + } + } + } +} + +export const ReconcileFormView = { + ...formView, + Controller: ReconcileFormController, +}; + +registry.category("views").add("reconcile_form", ReconcileFormView); diff --git a/account_reconcile_oca/static/src/js/reconcile_kanban_record.esm.js b/account_reconcile_oca/static/src/js/reconcile_kanban_record.esm.js new file mode 100644 index 00000000..a97c5258 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_kanban_record.esm.js @@ -0,0 +1,14 @@ +/** @odoo-module */ + +import {KanbanRecord} from "@web/views/kanban/kanban_record"; + +export class ReconcileKanbanRecord extends KanbanRecord { + getRecordClasses() { + var result = super.getRecordClasses(); + if (this.props.selectedRecordId === this.props.record.resId) { + result += " o_kanban_record_reconcile_oca_selected"; + } + return result; + } +} +ReconcileKanbanRecord.props = [...KanbanRecord.props, "selectedRecordId?"]; diff --git a/account_reconcile_oca/static/src/js/reconcile_manual_view.esm.js b/account_reconcile_oca/static/src/js/reconcile_manual_view.esm.js new file mode 100644 index 00000000..99be2b66 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_manual_view.esm.js @@ -0,0 +1,38 @@ +/** @odoo-module */ + +import {FormController} from "@web/views/form/form_controller"; +import {formView} from "@web/views/form/form_view"; +import {registry} from "@web/core/registry"; +import {useViewButtons} from "@web/views/view_button/view_button_hook"; +const {useRef} = owl; + +export class FormManualReconcileController extends FormController { + setup() { + super.setup(...arguments); + const rootRef = useRef("root"); + useViewButtons(this.model, rootRef, { + reload: this.reloadFormController.bind(this), + beforeExecuteAction: this.beforeExecuteActionButton.bind(this), + afterExecuteAction: this.afterExecuteActionButton.bind(this), + }); + } + async reloadFormController() { + try { + await this.model.root.load(); + } catch (error) { + // This should happen when we reconcile a line (no more possible data...) + if (this.env.parentController) { + await this.env.parentController.model.root.load(); + await this.env.parentController.render(true); + this.env.parentController.selectRecord(); + } + } + } +} + +export const FormManualReconcileView = { + ...formView, + Controller: FormManualReconcileController, +}; + +registry.category("views").add("reconcile_manual", FormManualReconcileView); diff --git a/account_reconcile_oca/static/src/js/reconcile_move_line_view.esm.js b/account_reconcile_oca/static/src/js/reconcile_move_line_view.esm.js new file mode 100644 index 00000000..158f8afa --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_move_line_view.esm.js @@ -0,0 +1,46 @@ +/** @odoo-module */ + +import {ListController} from "@web/views/list/list_controller"; +import {ListRenderer} from "@web/views/list/list_renderer"; +import {listView} from "@web/views/list/list_view"; +import {registry} from "@web/core/registry"; + +export class ReconcileMoveLineRenderer extends ListRenderer { + getRowClass(record) { + var classes = super.getRowClass(record); + if ( + this.props.parentRecord.data.reconcile_data_info.counterparts.includes( + record.resId + ) + ) { + classes += " o_field_account_reconcile_oca_move_line_selected"; + } + return classes; + } +} +ReconcileMoveLineRenderer.props = [ + ...ListRenderer.props, + "parentRecord", + "parentField", +]; +export class ReconcileMoveLineController extends ListController { + async openRecord(record) { + var data = {}; + data[this.props.parentField] = [record.resId, record.display_name]; + this.props.parentRecord.update(data); + } +} + +ReconcileMoveLineController.template = `account_reconcile_oca.ReconcileMoveLineController`; +ReconcileMoveLineController.props = { + ...ListController.props, + parentRecord: {type: Object, optional: true}, + parentField: {type: String, optional: true}, +}; +export const ReconcileMoveLineView = { + ...listView, + Controller: ReconcileMoveLineController, + Renderer: ReconcileMoveLineRenderer, +}; + +registry.category("views").add("reconcile_move_line", ReconcileMoveLineView); diff --git a/account_reconcile_oca/static/src/js/reconcile_move_line_widget.esm.js b/account_reconcile_oca/static/src/js/reconcile_move_line_widget.esm.js new file mode 100644 index 00000000..a539bca4 --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_move_line_widget.esm.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import {View} from "@web/views/view"; +import {registry} from "@web/core/registry"; + +const {Component, useSubEnv} = owl; + +export class AccountReconcileMatchWidget extends Component { + setup() { + // Necessary in order to avoid a loop + super.setup(...arguments); + useSubEnv({ + config: {}, + parentController: this.env.parentController, + }); + } + get listViewProperties() { + return { + type: "list", + display: { + controlPanel: { + // Hiding the control panel buttons + "top-left": false, + "bottom-left": false, + }, + }, + resModel: this.props.record.fields[this.props.name].relation, + searchMenuTypes: ["filter"], + domain: this.props.record.getFieldDomain(this.props.name).toList(), + context: { + ...this.props.record.getFieldContext(this.props.name), + }, + // Disables de selector + allowSelectors: false, + // We need to force the search view in order to show the right one + searchViewId: false, + parentRecord: this.props.record, + parentField: this.props.name, + }; + } +} +AccountReconcileMatchWidget.template = "account_reconcile_oca.ReconcileMatchWidget"; + +AccountReconcileMatchWidget.components = { + ...AccountReconcileMatchWidget.components, + View, +}; + +registry + .category("fields") + .add("account_reconcile_oca_match", AccountReconcileMatchWidget); diff --git a/account_reconcile_oca/static/src/js/reconcile_renderer.esm.js b/account_reconcile_oca/static/src/js/reconcile_renderer.esm.js new file mode 100644 index 00000000..d031e2bc --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_renderer.esm.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; +import {ReconcileKanbanRecord} from "./reconcile_kanban_record.esm.js"; +export class ReconcileRenderer extends KanbanRenderer {} + +ReconcileRenderer.components = { + ...KanbanRenderer.components, + KanbanRecord: ReconcileKanbanRecord, +}; +ReconcileRenderer.template = "account_reconcile_oca.ReconcileRenderer"; +ReconcileRenderer.props = [...KanbanRenderer.props, "selectedRecordId?"]; diff --git a/account_reconcile_oca/static/src/js/reconcile_view.esm.js b/account_reconcile_oca/static/src/js/reconcile_view.esm.js new file mode 100644 index 00000000..4bcb8ade --- /dev/null +++ b/account_reconcile_oca/static/src/js/reconcile_view.esm.js @@ -0,0 +1,16 @@ +/** @odoo-module */ + +import {ReconcileController} from "./reconcile_controller.esm.js"; +import {ReconcileRenderer} from "./reconcile_renderer.esm.js"; +import {kanbanView} from "@web/views/kanban/kanban_view"; +import {registry} from "@web/core/registry"; + +export const reconcileView = { + ...kanbanView, + Renderer: ReconcileRenderer, + Controller: ReconcileController, + buttonTemplate: "account_reconcile.ReconcileView.Buttons", + searchMenuTypes: ["filter"], +}; + +registry.category("views").add("reconcile", reconcileView); diff --git a/account_reconcile_oca/static/src/js/selection_badge_uncheck.esm.js b/account_reconcile_oca/static/src/js/selection_badge_uncheck.esm.js new file mode 100644 index 00000000..8d238cc6 --- /dev/null +++ b/account_reconcile_oca/static/src/js/selection_badge_uncheck.esm.js @@ -0,0 +1,29 @@ +/** @odoo-module **/ +import { + BadgeSelectionField, + preloadSelection, +} from "@web/views/fields/badge_selection/badge_selection_field"; +import {registry} from "@web/core/registry"; + +export class FieldSelectionBadgeUncheck extends BadgeSelectionField { + async onChange(value) { + var old_value = this.props.value; + if (this.props.type === "many2one") { + old_value = old_value[0]; + } + if (value === old_value) { + this.props.update(false); + return; + } + super.onChange(...arguments); + } +} + +FieldSelectionBadgeUncheck.supportedTypes = ["many2one", "selection"]; +FieldSelectionBadgeUncheck.additionalClasses = ["o_field_selection_badge"]; +registry.category("fields").add("selection_badge_uncheck", FieldSelectionBadgeUncheck); + +registry.category("preloadedData").add("selection_badge_uncheck", { + loadOnTypes: ["many2one"], + preload: preloadSelection, +}); diff --git a/account_reconcile_oca/static/src/scss/reconcile.scss b/account_reconcile_oca/static/src/scss/reconcile.scss new file mode 100644 index 00000000..840ceb23 --- /dev/null +++ b/account_reconcile_oca/static/src/scss/reconcile.scss @@ -0,0 +1,72 @@ +.o_account_reconcile_oca { + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-flex-flow: row wrap; + flex-flow: row wrap; + height: 100%; + .o_kanban_renderer.o_kanban_ungrouped .o_kanban_record { + margin: 0 0 0; + > div { + border-right: thick solid rgba(0, 0, 0, 0); + } + &.o_kanban_record_reconcile_oca_selected > div { + border-right: thick solid $o-brand-primary; + } + } + .o_account_reconcile_oca_selector { + width: 30%; + height: 100%; + padding: 0; + position: relative; + border-right: 1px solid $o-gray-300; + } + .o_account_reconcile_oca_info { + width: 70%; + height: 100%; + } + .o_form_view { + .btn-info:not(.dropdown-toggle):not(.dropdown-item) { + text-transform: uppercase; + } + .o_form_statusbar.o_account_reconcile_oca_statusbar { + .btn:not(.dropdown-toggle):not(.dropdown-item) { + text-transform: uppercase; + } + height: 40px; + > .o_statusbar_buttons { + height: 100%; + > .btn { + margin: 0; + height: 100%; + padding: 10px; + border-radius: 0; + } + } + } + .o_field_account_reconcile_oca_data { + .o_field_account_reconcile_oca_balance_float { + .o_field_account_reconcile_oca_balance_original_float { + text-decoration: line-through; + } + } + } + .o_field_widget.o_field_account_reconcile_oca_match { + display: inline; + } + .o_field_account_reconcile_oca_move_line_selected { + background-color: rgba($o-brand-primary, 0.2); + color: #000; + } + .o_reconcile_widget_table { + .o_reconcile_widget_line { + &.liquidity { + font-weight: bold; + } + } + } + } +} +.o_field_account_reconcile_oca_chatter { + width: 100%; +} diff --git a/account_reconcile_oca/static/src/xml/reconcile.xml b/account_reconcile_oca/static/src/xml/reconcile.xml new file mode 100644 index 00000000..63801923 --- /dev/null +++ b/account_reconcile_oca/static/src/xml/reconcile.xml @@ -0,0 +1,149 @@ + + + + + + + + + props.selectedRecordId + + + + + + state.selectedRecordId + + + model.useSampleModel ? 'o_view_sample_data o_account_reconcile_oca' : 'o_account_reconcile_oca' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AccountPartnerDateLabelDebitCredit +
+ + + + + + +
+
+ + + + + + props.parentRecord + props.parentField + + +
diff --git a/account_reconcile_oca/tests/__init__.py b/account_reconcile_oca/tests/__init__.py new file mode 100644 index 00000000..17f193ed --- /dev/null +++ b/account_reconcile_oca/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_bank_account_reconcile +from . import test_account_reconcile diff --git a/account_reconcile_oca/tests/test_account_reconcile.py b/account_reconcile_oca/tests/test_account_reconcile.py new file mode 100644 index 00000000..f68265ba --- /dev/null +++ b/account_reconcile_oca/tests/test_account_reconcile.py @@ -0,0 +1,304 @@ +from odoo.tests import Form, tagged + +from odoo.addons.account.tests.common import TestAccountReconciliationCommon + + +@tagged("post_install", "-at_install") +class TestReconciliationWidget(TestAccountReconciliationCommon): + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.acc_bank_stmt_model = cls.env["account.bank.statement"] + cls.acc_bank_stmt_line_model = cls.env["account.bank.statement.line"] + cls.bank_journal_usd.suspense_account_id = ( + cls.company.account_journal_suspense_account_id + ) + cls.bank_journal_euro.suspense_account_id = ( + cls.company.account_journal_suspense_account_id + ) + cls.current_assets_account = ( + cls.env["account.account"] + .search( + [ + ("account_type", "=", "asset_current"), + ("company_id", "=", cls.company.id), + ], + limit=1, + ) + .copy() + ) + cls.current_assets_account.reconcile = True + cls.asset_receivable_account = ( + cls.env["account.account"] + .search( + [ + ("account_type", "=", "asset_receivable"), + ("company_id", "=", cls.company.id), + ], + limit=1, + ) + .copy() + ) + cls.asset_receivable_account.reconcile = True + cls.equity_account = ( + cls.env["account.account"] + .search( + [ + ("account_type", "=", "equity"), + ("company_id", "=", cls.company.id), + ], + limit=1, + ) + .copy() + ) + cls.non_current_assets_account = ( + cls.env["account.account"] + .search( + [ + ("account_type", "=", "asset_non_current"), + ("company_id", "=", cls.company.id), + ], + limit=1, + ) + .copy() + ) + cls.non_current_assets_account.reconcile = True + cls.move_1 = cls.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": cls.current_assets_account.id, + "name": "DEMO", + "credit": 100, + }, + ), + ( + 0, + 0, + { + "account_id": cls.non_current_assets_account.id, + "name": "DEMO", + "debit": 100, + }, + ), + ] + } + ) + cls.move_1.action_post() + cls.move_2 = cls.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": cls.non_current_assets_account.id, + "name": "DEMO", + "credit": 50, + }, + ), + ( + 0, + 0, + { + "account_id": cls.equity_account.id, + "name": "DEMO", + "debit": 50, + }, + ), + ] + } + ) + cls.move_2.action_post() + cls.move_3 = cls.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": cls.non_current_assets_account.id, + "name": "DEMO", + "credit": 50, + }, + ), + ( + 0, + 0, + { + "account_id": cls.equity_account.id, + "name": "DEMO", + "debit": 50, + }, + ), + ] + } + ) + cls.move_3.action_post() + + def test_reconcile(self): + account = self.non_current_assets_account + reconcile_account = self.env["account.account.reconcile"].search( + [("account_id", "=", account.id)] + ) + self.assertTrue(reconcile_account) + with Form(reconcile_account) as f: + f.add_account_move_line_id = self.move_1.line_ids.filtered( + lambda r: r.account_id == account + ) + f.add_account_move_line_id = self.move_2.line_ids.filtered( + lambda r: r.account_id == account + ) + reconcile_account.reconcile() + reconcile_account = self.env["account.account.reconcile"].search( + [("account_id", "=", account.id)] + ) + self.assertTrue(reconcile_account) + with Form(reconcile_account) as f: + f.add_account_move_line_id = self.move_1.line_ids.filtered( + lambda r: r.account_id == account + ) + f.add_account_move_line_id = self.move_3.line_ids.filtered( + lambda r: r.account_id == account + ) + reconcile_account.reconcile() + reconcile_account = self.env["account.account.reconcile"].search( + [("account_id", "=", account.id)] + ) + self.assertFalse(reconcile_account) + + def test_clean_reconcile(self): + account = self.non_current_assets_account + reconcile_account = self.env["account.account.reconcile"].search( + [("account_id", "=", account.id)] + ) + self.assertTrue(reconcile_account) + with Form(reconcile_account) as f: + f.add_account_move_line_id = self.move_1.line_ids.filtered( + lambda r: r.account_id == account + ) + f.add_account_move_line_id = self.move_2.line_ids.filtered( + lambda r: r.account_id == account + ) + self.assertTrue(reconcile_account.reconcile_data_info.get("counterparts")) + reconcile_account.clean_reconcile() + self.assertFalse(reconcile_account.reconcile_data_info.get("counterparts")) + + def test_cannot_reconcile(self): + """ + There is not enough records to reconcile for this account + """ + reconcile_account = self.env["account.account.reconcile"].search( + [("account_id", "=", self.current_assets_account.id)] + ) + self.assertFalse(reconcile_account) + + def test_cannot_reconcile_different_partners(self): + """ + We can only reconcile lines with the same account and partner. + """ + reconcile_account = self.env["account.account.reconcile"].search( + [ + ("account_id", "=", self.asset_receivable_account.id), + ] + ) + self.assertFalse(reconcile_account) + move_1 = self.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.current_assets_account.id, + "name": "DEMO", + "credit": 100, + }, + ), + ( + 0, + 0, + { + "account_id": self.asset_receivable_account.id, + "partner_id": self.env.user.partner_id.id, + "name": "DEMO", + "debit": 100, + }, + ), + ] + } + ) + move_1.action_post() + self.env.flush_all() + move_2 = self.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.current_assets_account.id, + "name": "DEMO", + "debit": 100, + }, + ), + ( + 0, + 0, + { + "account_id": self.asset_receivable_account.id, + "partner_id": self.company.partner_id.id, + "name": "DEMO", + "credit": 100, + }, + ), + ] + } + ) + move_2.action_post() + self.env.flush_all() + reconcile_account = self.env["account.account.reconcile"].search( + [ + ("account_id", "=", self.asset_receivable_account.id), + ] + ) + self.assertFalse(reconcile_account) + + move_3 = self.env["account.move"].create( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.current_assets_account.id, + "name": "DEMO", + "debit": 100, + }, + ), + ( + 0, + 0, + { + "account_id": self.asset_receivable_account.id, + "partner_id": self.env.user.partner_id.id, + "name": "DEMO", + "credit": 100, + }, + ), + ] + } + ) + move_3.action_post() + self.env.flush_all() + reconcile_account = self.env["account.account.reconcile"].search( + [ + ("account_id", "=", self.asset_receivable_account.id), + ] + ) + self.assertTrue(reconcile_account) + self.assertEqual(reconcile_account.partner_id, self.env.user.partner_id) diff --git a/account_reconcile_oca/tests/test_bank_account_reconcile.py b/account_reconcile_oca/tests/test_bank_account_reconcile.py new file mode 100644 index 00000000..7d124f2c --- /dev/null +++ b/account_reconcile_oca/tests/test_bank_account_reconcile.py @@ -0,0 +1,706 @@ +import time + +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + +from odoo.addons.account.tests.common import TestAccountReconciliationCommon + + +@tagged("post_install", "-at_install") +class TestReconciliationWidget(TestAccountReconciliationCommon): + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.acc_bank_stmt_model = cls.env["account.bank.statement"] + cls.acc_bank_stmt_line_model = cls.env["account.bank.statement.line"] + cls.bank_journal_usd.suspense_account_id = ( + cls.company.account_journal_suspense_account_id + ) + cls.bank_journal_euro.suspense_account_id = ( + cls.company.account_journal_suspense_account_id + ) + cls.current_assets_account = cls.env["account.account"].search( + [ + ("account_type", "=", "asset_current"), + ("company_id", "=", cls.company.id), + ], + limit=1, + ) + cls.current_assets_account.reconcile = True + + cls.rule = cls.env["account.reconcile.model"].create( + { + "name": "write-off model", + "rule_type": "writeoff_button", + "match_partner": True, + "match_partner_ids": [], + "line_ids": [(0, 0, {"account_id": cls.current_assets_account.id})], + } + ) + # We need to make some fields visible in order to make the tests work + cls.env["ir.ui.view"].create( + { + "name": "DEMO Account bank statement", + "model": "account.bank.statement.line", + "inherit_id": cls.env.ref( + "account_reconcile_oca.bank_statement_line_form_reconcile_view" + ).id, + "arch": """ + + + 0 + + + 0 + + + 0 + + + """, + } + ) + + # Testing reconcile action + + def test_reconcile_invoice_unreconcile(self): + """ + We want to test the reconcile widget for bank statements on invoices. + As we use edit mode by default, we will also check what happens when + we press unreconcile + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(f.can_reconcile) + self.assertFalse(bank_stmt_line.is_reconciled) + self.assertTrue( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == self.bank_journal_euro.suspense_account_id + ) + ) + bank_stmt_line.reconcile_bank_line() + self.assertTrue(bank_stmt_line.is_reconciled) + self.assertFalse( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == self.bank_journal_euro.suspense_account_id + ) + ) + bank_stmt_line.unreconcile_bank_line() + self.assertFalse(bank_stmt_line.is_reconciled) + self.assertTrue( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == self.bank_journal_euro.suspense_account_id + ) + ) + + def test_reconcile_invoice_partial(self): + """ + We want to partially reconcile two invoices from a single payment. + As a result, both invoices must be partially reconciled + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + inv2 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + receivable2 = inv2.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable1 + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(f.can_reconcile) + f.manual_reference = "account.move.line;%s" % receivable1.id + self.assertEqual(f.manual_amount, -100) + f.manual_amount = -70 + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable2 + f.manual_reference = "account.move.line;%s" % receivable2.id + self.assertEqual(f.manual_amount, -30) + self.assertTrue(f.can_reconcile) + self.assertEqual(inv1.amount_residual, 100) + self.assertEqual(inv2.amount_residual, 100) + bank_stmt_line.reconcile_bank_line() + self.assertEqual(inv1.amount_residual, 30) + self.assertEqual(inv2.amount_residual, 70) + + def test_reconcile_model(self): + """ + We want to test what happens when we select an reconcile model to fill a + bank statement. + """ + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.manual_model_id = self.rule + self.assertTrue(f.can_reconcile) + bank_stmt_line.reconcile_bank_line() + self.assertTrue( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == self.current_assets_account + ) + ) + + def test_reconcile_invoice_model(self): + """ + We want to test what happens when we select a reconcile model to fill a + bank statement prefilled with an invoice. + + The result should be the reconcile of the invoice, and the rest set to the model + """ + + inv1 = self.create_invoice(currency_id=self.currency_euro_id) + + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable1 + self.assertFalse(f.can_reconcile) + f.manual_model_id = self.rule + self.assertTrue(f.can_reconcile) + bank_stmt_line.reconcile_bank_line() + self.assertNotEqual(self.current_assets_account, receivable1.account_id) + self.assertTrue( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == self.current_assets_account + ) + ) + self.assertTrue( + bank_stmt_line.move_id.line_ids.filtered( + lambda r: r.account_id == receivable1.account_id + ) + ) + self.assertEqual(0, inv1.amount_residual) + + def test_reconcile_rule_on_create(self): + """ + Testing the fill of the bank statment line with + writeoff suggestion reconcile model with auto_reconcile + """ + self.env["account.reconcile.model"].create( + { + "name": "write-off model suggestion", + "rule_type": "writeoff_suggestion", + "match_label": "contains", + "match_label_param": "DEMO WRITEOFF", + "auto_reconcile": True, + "line_ids": [(0, 0, {"account_id": self.current_assets_account.id})], + } + ) + + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "DEMO WRITEOFF", + "payment_ref": "DEMO WRITEOFF", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + self.assertTrue(bank_stmt_line.is_reconciled) + + def test_reconcile_invoice_keep(self): + """ + We want to test how the keep mode works, keeping the original move lines. + However, the unreconcile will not work properly + """ + self.bank_journal_euro.reconcile_mode = "keep" + self.bank_journal_euro.suspense_account_id.reconcile = True + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable1 + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(bank_stmt_line.can_reconcile) + bank_stmt_line.reconcile_bank_line() + self.assertIn( + self.bank_journal_euro.suspense_account_id, + bank_stmt_line.mapped("move_id.line_ids.account_id"), + ) + with self.assertRaises(UserError): + bank_stmt_line.unreconcile_bank_line() + + # Testing widget + + def test_widget_invoice_clean(self): + """ + We want to test how the clean works on an already defined bank statement + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable1 + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(bank_stmt_line.can_reconcile) + bank_stmt_line.clean_reconcile() + self.assertFalse(bank_stmt_line.can_reconcile) + + def test_widget_invoice_delete(self): + """ + We need to test the possibility to remove a line from the reconcile widget + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = receivable1 + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(f.can_reconcile) + f.manual_reference = "account.move.line;%s" % receivable1.id + self.assertEqual(f.manual_amount, -100) + f.manual_delete = True + self.assertFalse(f.can_reconcile) + + def test_widget_invoice_unselect(self): + """ + We want to test how selection and unselection of an account move lines is managed + by the system. + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.add_account_move_line_id = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + self.assertFalse(f.add_account_move_line_id) + self.assertTrue(f.can_reconcile) + f.add_account_move_line_id = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + self.assertFalse(f.add_account_move_line_id) + self.assertFalse(f.can_reconcile) + + def test_widget_invoice_change_partner(self): + """ + We want to know how the change of partner of + a bank statement line is managed + """ + inv1 = self.create_invoice( + currency_id=self.currency_euro_id, invoice_amount=100 + ) + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + liquidity_lines, suspense_lines, other_lines = bank_stmt_line._seek_for_lines() + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + self.assertFalse(f.partner_id) + f.manual_reference = "account.move.line;%s" % liquidity_lines.id + f.manual_partner_id = inv1.partner_id + self.assertEqual(f.partner_id, inv1.partner_id) + bank_stmt_line.clean_reconcile() + # As we have a set a partner, the cleaning should assign the invoice automatically + self.assertTrue(bank_stmt_line.can_reconcile) + + def test_widget_model_clean(self): + """ + We want to test what happens when we select an reconcile model to fill a + bank statement. + """ + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + with Form( + bank_stmt_line, + view="account_reconcile_oca.bank_statement_line_form_reconcile_view", + ) as f: + self.assertFalse(f.can_reconcile) + f.manual_model_id = self.rule + self.assertTrue(f.can_reconcile) + # We need to check what happens when we uncheck it too + f.manual_model_id = self.env["account.reconcile.model"] + self.assertFalse(f.can_reconcile) + f.manual_model_id = self.rule + self.assertTrue(f.can_reconcile) + + # Testing actions + + def test_bank_statement_action_to_check(self): + action = self.bank_journal_euro.action_open_reconcile_to_check() + self.assertFalse(self.env[action["res_model"]].search(action["domain"])) + + def test_bank_statement_rainbowman(self): + message = self.bank_journal_euro.get_rainbowman_message() + self.assertTrue(message) + self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + self.env.flush_all() + message = self.bank_journal_euro.get_rainbowman_message() + self.assertFalse(message) + + def test_bank_statement_line_actions(self): + """ + Testing the actions of bank statement + """ + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + move_action = bank_stmt_line.action_show_move() + self.assertEqual( + bank_stmt_line.move_id, + self.env[move_action["res_model"]].browse(move_action["res_id"]), + ) + + # Testing filters + + def test_filter_partner(self): + """ + When a partner is set, the system might try to define an existent + invoice automatically + """ + inv1 = self.create_invoice(currency_id=self.currency_euro_id) + inv2 = self.create_invoice(currency_id=self.currency_euro_id) + partner = inv1.partner_id + + receivable1 = inv1.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + self.assertTrue(receivable1) + receivable2 = inv2.line_ids.filtered( + lambda l: l.account_id.account_type == "asset_receivable" + ) + self.assertTrue(receivable2) + + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "journal_id": self.bank_journal_euro.id, + "statement_id": bank_stmt.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + } + ) + + # Without a partner set, No default data + + bkstmt_data = bank_stmt_line.reconcile_data_info + mv_lines_ids = bkstmt_data["counterparts"] + self.assertNotIn(receivable1.id, mv_lines_ids) + self.assertNotIn(receivable2.id, mv_lines_ids) + + # This is like input a partner in the widget + + bank_stmt_line.partner_id = partner + bank_stmt_line.flush_recordset() + bank_stmt_line.invalidate_recordset() + bkstmt_data = bank_stmt_line.reconcile_data_info + mv_lines_ids = bkstmt_data["counterparts"] + + self.assertIn(receivable1.id, mv_lines_ids) + self.assertIn(receivable2.id, mv_lines_ids) + + # With a partner set, type the invoice reference in the filter + bank_stmt_line.payment_ref = inv1.payment_reference + bank_stmt_line.flush_recordset() + bank_stmt_line.invalidate_recordset() + bkstmt_data = bank_stmt_line.reconcile_data_info + mv_lines_ids = bkstmt_data["counterparts"] + + self.assertIn(receivable1.id, mv_lines_ids) + self.assertNotIn(receivable2.id, mv_lines_ids) + + def test_partner_name_with_parent(self): + parent_partner = self.env["res.partner"].create( + { + "name": "test", + } + ) + child_partner = self.env["res.partner"].create( + { + "name": "test", + "parent_id": parent_partner.id, + "type": "delivery", + } + ) + self.create_invoice_partner( + currency_id=self.currency_euro_id, partner_id=child_partner.id + ) + + bank_stmt = self.acc_bank_stmt_model.create( + { + "company_id": self.env.ref("base.main_company").id, + "journal_id": self.bank_journal_euro.id, + "date": time.strftime("%Y-07-15"), + "name": "test", + } + ) + + bank_stmt_line = self.acc_bank_stmt_line_model.create( + { + "name": "testLine", + "statement_id": bank_stmt.id, + "journal_id": self.bank_journal_euro.id, + "amount": 100, + "date": time.strftime("%Y-07-15"), + "payment_ref": "test", + "partner_name": "test", + } + ) + + bkstmt_data = bank_stmt_line.reconcile_data_info + self.assertEqual(len(bkstmt_data["counterparts"]), 1) + self.assertEqual( + self.env["account.move.line"] + .browse(bkstmt_data["counterparts"]) + .partner_id, + parent_partner, + ) diff --git a/account_reconcile_oca/views/account_account.xml b/account_reconcile_oca/views/account_account.xml new file mode 100644 index 00000000..a8207ce6 --- /dev/null +++ b/account_reconcile_oca/views/account_account.xml @@ -0,0 +1,23 @@ + + + + + + account.account.tree (in account_reconcile_oca) + account.account + + + + + + + diff --git a/account_reconcile_oca/views/account_account_reconcile.xml b/account_reconcile_oca/views/account_account_reconcile.xml new file mode 100644 index 00000000..63cf5687 --- /dev/null +++ b/account_reconcile_oca/views/account_account_reconcile.xml @@ -0,0 +1,165 @@ + + + + + + account.account.reconcile.form (in account_reconcile_oca) + account.account.reconcile + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + account.account.reconcile.search (in account_reconcile_oca) + account.account.reconcile + + + + + + + + + account.account.reconcile.tree (in account_reconcile_oca) + account.account.reconcile + + + + + + + + + + + + account.account.reconcile.kanban (in account_reconcile_oca) + account.account.reconcile + + + + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + Reconcile + account.account.reconcile + kanban + [] + {'view_ref': 'account_reconcile_oca.account_account_reconcile_form_view'} + + + Reconcile + account.account.reconcile + kanban + [("partner_id", "=", active_id)] + {'view_ref': 'account_reconcile_oca.account_account_reconcile_form_view'} + + form + + + + Reconcile + account.account.reconcile + kanban + [("account_id", "=", active_id)] + {'view_ref': 'account_reconcile_oca.account_account_reconcile_form_view'} + + form + + + + Reconcile + + + + + +
diff --git a/account_reconcile_oca/views/account_bank_statement_line.xml b/account_reconcile_oca/views/account_bank_statement_line.xml new file mode 100644 index 00000000..dadd124e --- /dev/null +++ b/account_reconcile_oca/views/account_bank_statement_line.xml @@ -0,0 +1,326 @@ + + + + + + account.bank.statement.line.reconcile + account.bank.statement.line + + + + + + + + + + + + + + + + account.bank.statement.line.reconcile + account.bank.statement.line + + + + + + + +
+
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+
+
+ Reconciled +
+
+
+
+
+
+
+
+
+ + + account.bank.statement.line.form + account.bank.statement.line + +
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + account.bank.statement.line.reconcile + account.bank.statement.line + 99 + +
+ + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + Reconcile bank statement lines + account.bank.statement.line + [('journal_id', '=', active_id)] + {'default_journal_id': active_id, 'search_default_not_reconciled': True, 'view_ref': 'account_reconcile_oca.bank_statement_line_form_reconcile_view'} + tree + + +

+ Nothing to reconcile +

+
+
+ + Reconcile bank statement lines + account.bank.statement.line + [('journal_id', '=', active_id)] + {'default_journal_id': active_id, 'view_ref': 'account_reconcile_oca.bank_statement_line_form_reconcile_view'} + tree + + +

+ Nothing to reconcile +

+
+
+ + + Reconcile bank statement lines + account.bank.statement.line + {'search_default_move_id': active_id} + tree + + +

+ Nothing to reconcile +

+
+
+ + Add an Statement + account.bank.statement.line + form + + new + +
diff --git a/account_reconcile_oca/views/account_journal.xml b/account_reconcile_oca/views/account_journal.xml new file mode 100644 index 00000000..3947f5c4 --- /dev/null +++ b/account_reconcile_oca/views/account_journal.xml @@ -0,0 +1,70 @@ + + + + + + account.journal.inherit.dashboard.kanban + account.journal + + + + + + + + + account.journal.inherit.dashboard.kanban + account.journal + + + + + + + + + +
+ +
+ + + +
+
+
+
+ + + + +
+
+
diff --git a/account_reconcile_oca/views/account_move.xml b/account_reconcile_oca/views/account_move.xml new file mode 100644 index 00000000..04ee2f11 --- /dev/null +++ b/account_reconcile_oca/views/account_move.xml @@ -0,0 +1,26 @@ + + + + + + account.move.form (in account_reconcile_oca) + account.move + + +
+
+
+
+ + + +
diff --git a/account_reconcile_oca/views/account_move_line.xml b/account_reconcile_oca/views/account_move_line.xml new file mode 100644 index 00000000..a5c680ac --- /dev/null +++ b/account_reconcile_oca/views/account_move_line.xml @@ -0,0 +1,98 @@ + + + + + + account.move.line.tree.reconcile + account.move.line + 99 + + + + + + + +