diff --git a/account_move_line_reconcile_manual/README.rst b/account_move_line_reconcile_manual/README.rst new file mode 100644 index 00000000..262ac821 --- /dev/null +++ b/account_move_line_reconcile_manual/README.rst @@ -0,0 +1 @@ +Will be auto-generated from readme subdir diff --git a/account_move_line_reconcile_manual/__init__.py b/account_move_line_reconcile_manual/__init__.py new file mode 100644 index 00000000..5cb1c491 --- /dev/null +++ b/account_move_line_reconcile_manual/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/account_move_line_reconcile_manual/__manifest__.py b/account_move_line_reconcile_manual/__manifest__.py new file mode 100644 index 00000000..24260561 --- /dev/null +++ b/account_move_line_reconcile_manual/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Account Move Line Reconcile Manual", + "version": "14.0.1.0.0", + "category": "Accounting", + "license": "AGPL-3", + "summary": "Manually reconcile Journal Items", + "author": "Akretion,Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/account-reconcile", + "depends": ["account"], + "data": [ + "security/ir.model.access.csv", + "wizards/account_move_line_reconcile_manual_view.xml", + "views/account_move_line.xml", + ], + "installable": True, +} diff --git a/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst b/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..ff65d68c --- /dev/null +++ b/account_move_line_reconcile_manual/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexis de Lattre diff --git a/account_move_line_reconcile_manual/readme/DESCRIPTION.rst b/account_move_line_reconcile_manual/readme/DESCRIPTION.rst new file mode 100644 index 00000000..dce3ab57 --- /dev/null +++ b/account_move_line_reconcile_manual/readme/DESCRIPTION.rst @@ -0,0 +1,18 @@ +This module adds a wizard to reconcile manually selected journal items. If the selected journal items are balanced, it will propose a full reconcile. Otherwise, the user will have to choose between partial reconciliation and full reconciliation with a write-off. + +For the old-time Odoo users, the feature provided by this module is similar to the wizard that was provided in the **account** module up to Odoo 11.0. It was later replaced by the special reconciliation JS interface, which was working well, but was not as fast and convenient. + +Full reconciliation: + +.. figure:: ../static/description/sshot_full_rec.png + :alt: Full reconciliation + +Choose between partial reconciliation and full reconciliation with a write-off: + +.. figure:: ../static/description/sshot_partial_rec.png + :alt: Choose between partial reconciliation and full reconciliation with a write-off + +Reconcile with write-off: + +.. figure:: ../static/description/sshot_rec_writeoff.png + :alt: Reconcile with write-off diff --git a/account_move_line_reconcile_manual/security/ir.model.access.csv b/account_move_line_reconcile_manual/security/ir.model.access.csv new file mode 100644 index 00000000..8f52a05f --- /dev/null +++ b/account_move_line_reconcile_manual/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_move_line_reconcile_manual,Full access on account.move.line.reconcile.manual wizard,model_account_move_line_reconcile_manual,account.group_account_user,1,1,1,1 diff --git a/account_move_line_reconcile_manual/static/description/sshot_full_rec.png b/account_move_line_reconcile_manual/static/description/sshot_full_rec.png new file mode 100644 index 00000000..d39e00ca Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_full_rec.png differ diff --git a/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png b/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png new file mode 100644 index 00000000..4f563da6 Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_partial_rec.png differ diff --git a/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png b/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png new file mode 100644 index 00000000..6ca9ddfd Binary files /dev/null and b/account_move_line_reconcile_manual/static/description/sshot_rec_writeoff.png differ diff --git a/account_move_line_reconcile_manual/tests/__init__.py b/account_move_line_reconcile_manual/tests/__init__.py new file mode 100644 index 00000000..39802dd0 --- /dev/null +++ b/account_move_line_reconcile_manual/tests/__init__.py @@ -0,0 +1 @@ +from . import test_reconcile_manual diff --git a/account_move_line_reconcile_manual/tests/test_reconcile_manual.py b/account_move_line_reconcile_manual/tests/test_reconcile_manual.py new file mode 100644 index 00000000..54fbfad1 --- /dev/null +++ b/account_move_line_reconcile_manual/tests/test_reconcile_manual.py @@ -0,0 +1,176 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tests.common import SavepointCase + + +@tagged("post_install", "-at_install") +class TestReconcileManual(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.company = cls.env.ref("base.main_company") + cls.ccur = cls.company.currency_id + cls.rec_account = cls.env["account.account"].search( + [("company_id", "=", cls.company.id), ("reconcile", "=", True)], limit=1 + ) + cls.other_account = cls.env["account.account"].search( + [("company_id", "=", cls.company.id), ("reconcile", "=", False)], limit=1 + ) + cls.journal = cls.env["account.journal"].search( + [("company_id", "=", cls.company.id), ("type", "=", "general")], limit=1 + ) + cls.partner = cls.env["res.partner"].create( + {"name": "Odoo Community Association", "company_id": cls.company.id} + ) + cls.move1 = cls.env["account.move"].create( + { + "journal_id": cls.journal.id, + "company_id": cls.company.id, + "line_ids": [ + ( + 0, + 0, + { + "account_id": cls.rec_account.id, + "partner_id": cls.partner.id, + "debit": 100, + }, + ), + ( + 0, + 0, + { + "account_id": cls.other_account.id, + "partner_id": cls.partner.id, + "credit": 100, + }, + ), + ], + } + ) + cls.move1._post(soft=False) + cls.line1 = cls.move1.line_ids.filtered( + lambda x: x.account_id == cls.rec_account + ) + cls.move2 = cls.env["account.move"].create( + { + "journal_id": cls.journal.id, + "company_id": cls.company.id, + "line_ids": [ + ( + 0, + 0, + { + "account_id": cls.rec_account.id, + "partner_id": cls.partner.id, + "credit": 95, + }, + ), + ( + 0, + 0, + { + "account_id": cls.other_account.id, + "partner_id": cls.partner.id, + "debit": 95, + }, + ), + ], + } + ) + cls.move2._post(soft=False) + cls.line2 = cls.move2.line_ids.filtered( + lambda x: x.account_id == cls.rec_account + ) + cls.writeoff_account = cls.env["account.account"].search( + [ + ("company_id", "=", cls.company.id), + ("reconcile", "=", False), + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_expenses").id, + ), + ], + limit=1, + ) + cls.writeoff_ref = "OCApower" + + def test_reconcile_manual(self): + # start with partial reconcile + lines_to_rec = self.line1 + self.line2 + wiz1 = ( + self.env["account.move.line.reconcile.manual"] + .with_context(active_model="account.move.line", active_ids=lines_to_rec.ids) + .create({}) + ) + self.assertEqual(wiz1.account_id, self.rec_account) + self.assertEqual(wiz1.company_id, self.company) + self.assertEqual(wiz1.count, 2) + self.assertEqual(wiz1.partner_count, 1) + self.assertEqual(wiz1.partner_id, self.partner) + self.assertFalse(self.ccur.compare_amounts(wiz1.total_debit, 100)) + self.assertFalse(self.ccur.compare_amounts(wiz1.total_credit, 95)) + self.assertEqual(wiz1.writeoff_type, "expense") + wiz1.partial_reconcile() + + # reconcile with write-off + wiz2 = ( + self.env["account.move.line.reconcile.manual"] + .with_context(active_model="account.move.line", active_ids=lines_to_rec.ids) + .create({}) + ) + self.assertEqual(wiz2.writeoff_type, "expense") + wiz2.go_to_writeoff() + self.assertEqual(wiz2.state, "writeoff") + self.assertFalse(self.ccur.compare_amounts(wiz2.writeoff_amount, 5)) + wiz2.write( + { + "writeoff_journal_id": self.journal.id, + "writeoff_ref": self.writeoff_ref, + "writeoff_account_id": self.writeoff_account.id, + } + ) + action2 = wiz2.reconcile_with_writeoff() + self.assertEqual(action2.get("type"), "ir.actions.client") + wo_move = self.env["account.move"].search( + [("company_id", "=", self.company.id)], order="id desc", limit=1 + ) + self.assertEqual(wo_move.ref, self.writeoff_ref) + self.assertEqual(wo_move.journal_id, self.journal) + self.assertEqual(wo_move.state, "posted") + self.assertEqual(wo_move.company_id, self.company) + wo_line = wo_move.line_ids.filtered(lambda x: x.account_id == self.rec_account) + full_rec2 = wo_line.full_reconcile_id + self.assertTrue(full_rec2) + self.assertEqual(self.line1.full_reconcile_id, full_rec2) + self.assertEqual(self.line2.full_reconcile_id, full_rec2) + + # Cannot start wizard on lines fully reconciled! + lines_to_rec += wo_line + with self.assertRaises(UserError): + self.env["account.move.line.reconcile.manual"].with_context( + active_model="account.move.line", active_ids=lines_to_rec.ids + ).create({}) + + # Full reconcile + lines_to_rec.remove_move_reconcile() + wiz4 = ( + self.env["account.move.line.reconcile.manual"] + .with_context(active_model="account.move.line", active_ids=lines_to_rec.ids) + .create({}) + ) + self.assertEqual(wiz4.writeoff_type, "none") + self.assertFalse(self.ccur.compare_amounts(wiz4.total_debit, 100)) + self.assertFalse(self.ccur.compare_amounts(wiz4.total_credit, 100)) + action4 = wiz4.full_reconcile() + self.assertEqual(action4.get("type"), "ir.actions.client") + full_rec4 = wo_line.full_reconcile_id + self.assertTrue(full_rec4) + self.assertEqual(self.line1.full_reconcile_id, full_rec4) + self.assertEqual(self.line2.full_reconcile_id, full_rec4) diff --git a/account_move_line_reconcile_manual/views/account_move_line.xml b/account_move_line_reconcile_manual/views/account_move_line.xml new file mode 100644 index 00000000..5fbaddb0 --- /dev/null +++ b/account_move_line_reconcile_manual/views/account_move_line.xml @@ -0,0 +1,26 @@ + + + + + + account.move.line + + + +
+
+
+
+
+ +
diff --git a/account_move_line_reconcile_manual/wizards/__init__.py b/account_move_line_reconcile_manual/wizards/__init__.py new file mode 100644 index 00000000..1ce47f66 --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_move_line_reconcile_manual diff --git a/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py new file mode 100644 index 00000000..bd3f9eaa --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual.py @@ -0,0 +1,286 @@ +# Copyright 2023 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +logger = logging.getLogger(__name__) + + +class AccountMoveLineReconcileManual(models.TransientModel): + _name = "account.move.line.reconcile.manual" + _description = "Manual Reconciliation Wizard" + _check_company_auto = True + + account_id = fields.Many2one( + "account.account", required=True, readonly=True, check_company=True + ) + company_id = fields.Many2one("res.company", required=True, readonly=True) + company_currency_id = fields.Many2one(related="company_id.currency_id") + count = fields.Integer(string="# of Journal Items", readonly=True) + total_debit = fields.Monetary(currency_field="company_currency_id", readonly=True) + total_credit = fields.Monetary(currency_field="company_currency_id", readonly=True) + move_line_ids = fields.Many2many( + "account.move.line", readonly=True, check_company=True + ) + partner_count = fields.Integer(readonly=True) + partner_id = fields.Many2one("res.partner", readonly=True) + state = fields.Selection( + [ + ("start", "Start"), + ("writeoff", "Write-off"), + ], + readonly=True, + default="start", + ) + # START WRITE-OFF FIELDS + writeoff_journal_id = fields.Many2one( + "account.journal", + string="Journal", + domain="[('type', '=', 'general'), ('company_id', '=', company_id)]", + check_company=True, + ) + writeoff_date = fields.Date(string="Date", default=fields.Date.context_today) + writeoff_ref = fields.Char(string="Reference", default=lambda self: _("Write-off")) + writeoff_type = fields.Selection( + [ + ("income", "Income"), + ("expense", "Expense"), + ("none", "None"), + ], + readonly=True, + string="Type", + ) + writeoff_amount = fields.Monetary( + currency_field="company_currency_id", readonly=True, string="Amount" + ) + writeoff_account_id = fields.Many2one( + "account.account", + string="Write-off Account", + domain="[('company_id', '=', company_id), ('deprecated', '=', False)]", + check_company=True, + ) + writeoff_analytic_account_id = fields.Many2one( + "account.analytic.account", + string="Write-off Analytic Account", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + check_company=True, + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if self._context.get("active_model") == self._name: # write-off step + return res + assert self._context.get("active_model") == "account.move.line" + move_lines = self.env["account.move.line"].browse( + self._context.get("active_ids") + ) + company = move_lines[0].account_id.company_id + ccur = company.currency_id + count = 0 + account = False + total_debit = total_credit = 0.0 + partner_set = set() + for line in move_lines: + count += 1 + total_debit += line.debit + total_credit += line.credit + if line.full_reconcile_id: + raise UserError( + _("Line '%s' is already fully reconciled.") % line.display_name + ) + if account: + if account != line.account_id: + raise UserError( + _( + "The Journal Items selected have different accounts: " + "%(account1)s and %(account2)s.", + account1=account.code, + account2=line.account_id.code, + ) + ) + else: + account = line.account_id + if line.partner_id: + partner_set.add(line.partner_id.id) + # if lines have the same account, they are in the same company + if not account.reconcile: + raise UserError( + _("Account '%s' is not reconciliable.") % account.display_name + ) + if count <= 1: + raise UserError(_("You must select at least 2 journal items!")) + if ccur.is_zero(total_debit): + raise UserError(_("You selected only credit journal items.")) + if ccur.is_zero(total_credit): + raise UserError(_("You selected only debit journal items.")) + writeoff_amount = ccur.round(abs(total_debit - total_credit)) + total_debit = ccur.round(total_debit) + total_credit = ccur.round(total_credit) + compare_res = ccur.compare_amounts(total_debit, total_credit) + if compare_res > 0: + writeoff_type = "expense" + elif compare_res < 0: + writeoff_type = "income" + else: + writeoff_type = "none" + general_journals = self.env["account.journal"].search( + [("type", "=", "general"), ("company_id", "=", company.id)] + ) + writeoff_journal_id = False + if len(general_journals) == 1: + writeoff_journal_id = general_journals.id + res.update( + { + "count": count, + "account_id": account.id, + "company_id": account.company_id.id, + "total_debit": total_debit, + "total_credit": total_credit, + "partner_count": len(partner_set), + "partner_id": len(partner_set) == 1 and partner_set.pop() or False, + "move_line_ids": move_lines.ids, + "writeoff_type": writeoff_type, + "writeoff_amount": writeoff_amount, + "writeoff_journal_id": writeoff_journal_id, + } + ) + return res + + def full_reconcile(self): + self.ensure_one() + self.move_line_ids.remove_move_reconcile() + res = self.move_line_ids.reconcile() + if not res.get("full_reconcile"): + raise UserError(_("Full reconciliation failed. It should never happen!")) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Successful reconciliation"), + "message": _("Reconcile mark: %s") % res["full_reconcile"].display_name, + "next": {"type": "ir.actions.act_window_close"}, + }, + } + return action + + def partial_reconcile(self): + self.ensure_one() + self.move_line_ids.remove_move_reconcile() + self.move_line_ids.reconcile() + return + + def go_to_writeoff(self): + self.ensure_one() + self.write({"state": "writeoff"}) + action = self.env["ir.actions.actions"]._for_xml_id( + "account_move_line_reconcile_manual.account_move_line_reconcile_manual_action" + ) + action["res_id"] = self.id + return action + + def _prepare_writeoff_move(self): + ccur = self.company_currency_id + bal = ccur.round(self.total_debit - self.total_credit) + compare_res = ccur.compare_amounts(bal, 0) + assert compare_res + if compare_res > 0: + credit = bal + debit = 0 + else: + debit = bal * -1 + credit = 0 + vals = { + "company_id": self.company_id.id, + "journal_id": self.writeoff_journal_id.id, + "ref": self.writeoff_ref, + "date": self.writeoff_date, + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.account_id.id, + "partner_id": self.partner_id and self.partner_id.id or False, + "debit": debit, + "credit": credit, + }, + ), + ( + 0, + 0, + { + "account_id": self.writeoff_account_id.id, + "partner_id": self.partner_id and self.partner_id.id or False, + "debit": credit, + "credit": debit, + "analytic_account_id": self.writeoff_analytic_account_id.id + or False, + }, + ), + ], + } + return vals + + def reconcile_with_writeoff(self): + self.ensure_one() + assert self.writeoff_journal_id + assert self.writeoff_date + assert self.writeoff_account_id + assert self.state == "writeoff" + self.move_line_ids.remove_move_reconcile() + vals = self._prepare_writeoff_move() + woff_move = self.env["account.move"].create(vals) + woff_move._post(soft=False) + to_rec_woff_line = woff_move.line_ids.filtered( + lambda x: x.account_id.id == self.account_id.id + ) + assert len(to_rec_woff_line) == 1 + to_rec_lines = self.move_line_ids + to_rec_woff_line + res = to_rec_lines.reconcile() + if not res.get("full_reconcile"): + raise UserError(_("Full reconciliation failed. It should never happen!")) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Successful reconciliation"), + "message": _( + "Write-off journal entry: %(writeoff_move)s\nReconcile mark: %(full_rec)s", + full_rec=res["full_reconcile"].display_name, + writeoff_move=woff_move.name, + ), + "next": {"type": "ir.actions.act_window_close"}, + }, + } + return action + + @api.onchange("writeoff_account_id") + def writeoff_account_id_change(self): + account = self.writeoff_account_id + if ( + self.writeoff_type in ("income", "expense") + and account + and self.writeoff_type != account.internal_group + ): + message = _( + "This is a/an '%(writeoff_type)s' write-off, " + "but you selected account %(account_code)s " + "which is a/an '%(account_type)s' account.", + writeoff_type=self._fields["writeoff_type"].convert_to_export( + self.writeoff_type, self + ), + account_code=account.code, + account_type=account.user_type_id.name, + ) + res = { + "warning": { + "title": _("Bad write-off account type"), + "message": message, + } + } + return res diff --git a/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml new file mode 100644 index 00000000..5b1753b0 --- /dev/null +++ b/account_move_line_reconcile_manual/wizards/account_move_line_reconcile_manual_view.xml @@ -0,0 +1,98 @@ + + + + + + account.move.line.reconcile.manual + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Reconcile + account.move.line.reconcile.manual + form + new + + +
diff --git a/account_reconcile_payment_order/tests/__init__.py b/account_reconcile_payment_order/tests/__init__.py index 90ea9363..7a720517 100644 --- a/account_reconcile_payment_order/tests/__init__.py +++ b/account_reconcile_payment_order/tests/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from . import test_account_reconcile_payment_order +# Tests (and module?) must be ported to new version of account_payment_order +# from . import test_account_reconcile_payment_order diff --git a/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual b/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual new file mode 120000 index 00000000..74cc94cf --- /dev/null +++ b/setup/account_move_line_reconcile_manual/odoo/addons/account_move_line_reconcile_manual @@ -0,0 +1 @@ +../../../../account_move_line_reconcile_manual \ No newline at end of file diff --git a/setup/account_move_line_reconcile_manual/setup.py b/setup/account_move_line_reconcile_manual/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/account_move_line_reconcile_manual/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)