From 6c012682235b1b05e0673097e597cb4e66de586a Mon Sep 17 00:00:00 2001 From: Eduardo de Miguel Date: Mon, 24 Nov 2025 14:54:57 +0100 Subject: [PATCH 1/3] [ADD] hr_expense_employee_payment --- hr_expense_employee_payment/README.rst | 154 ++++++ hr_expense_employee_payment/__init__.py | 1 + hr_expense_employee_payment/__manifest__.py | 23 + .../data/ir_actions_server.xml | 11 + hr_expense_employee_payment/i18n/es.po | 88 ++++ .../i18n/hr_expense_employee_payment.pot | 77 +++ .../models/__init__.py | 3 + .../models/hr_expense_sheet.py | 332 ++++++++++++ .../models/res_company.py | 17 + .../models/res_config_settings.py | 16 + hr_expense_employee_payment/pyproject.toml | 3 + .../readme/CONFIGURE.md | 8 + hr_expense_employee_payment/readme/CONTEXT.md | 7 + .../readme/CONTRIBUTORS.md | 2 + hr_expense_employee_payment/readme/CREDITS.md | 3 + .../readme/DESCRIPTION.md | 6 + hr_expense_employee_payment/readme/USAGE.md | 12 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 493 ++++++++++++++++++ hr_expense_employee_payment/tests/__init__.py | 1 + .../tests/test_hr_expense_sheet.py | 316 +++++++++++ .../views/res_config_settings_views.xml | 24 + 22 files changed, 1597 insertions(+) create mode 100644 hr_expense_employee_payment/README.rst create mode 100644 hr_expense_employee_payment/__init__.py create mode 100644 hr_expense_employee_payment/__manifest__.py create mode 100644 hr_expense_employee_payment/data/ir_actions_server.xml create mode 100644 hr_expense_employee_payment/i18n/es.po create mode 100644 hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot create mode 100644 hr_expense_employee_payment/models/__init__.py create mode 100644 hr_expense_employee_payment/models/hr_expense_sheet.py create mode 100644 hr_expense_employee_payment/models/res_company.py create mode 100644 hr_expense_employee_payment/models/res_config_settings.py create mode 100644 hr_expense_employee_payment/pyproject.toml create mode 100644 hr_expense_employee_payment/readme/CONFIGURE.md create mode 100644 hr_expense_employee_payment/readme/CONTEXT.md create mode 100644 hr_expense_employee_payment/readme/CONTRIBUTORS.md create mode 100644 hr_expense_employee_payment/readme/CREDITS.md create mode 100644 hr_expense_employee_payment/readme/DESCRIPTION.md create mode 100644 hr_expense_employee_payment/readme/USAGE.md create mode 100644 hr_expense_employee_payment/static/description/icon.png create mode 100644 hr_expense_employee_payment/static/description/index.html create mode 100644 hr_expense_employee_payment/tests/__init__.py create mode 100644 hr_expense_employee_payment/tests/test_hr_expense_sheet.py create mode 100644 hr_expense_employee_payment/views/res_config_settings_views.xml diff --git a/hr_expense_employee_payment/README.rst b/hr_expense_employee_payment/README.rst new file mode 100644 index 000000000..504de61a7 --- /dev/null +++ b/hr_expense_employee_payment/README.rst @@ -0,0 +1,154 @@ +============================= +Hr Expense - Employee Payment +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:01267e882ec6fd7a95898e583d214925d9a5b9d4f49bb8c3d49853a763696792 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--expense-lightgray.png?logo=github + :target: https://github.com/OCA/hr-expense/tree/18.0/hr_expense_employee_payment + :alt: OCA/hr-expense +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-expense-18-0/hr-expense-18-0-hr_expense_employee_payment + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr-expense&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the expense functionality so that expenses sheets +paid by employees, which are actual Vendor Bills, can be paid to the +employee. + +To do this, an intermediate journal entry is created that transfers the +debt from the supplier to the employee and also modifies the payment +wizard so that the employee receives the payment. Partial payments are +also compatible. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module has been created to allow payment to the employee when +supplier invoices are recorded in expenses. + +It will be useful for you if the legislation in your country doesn't +allow to create vendor bills to the employees. + +Each expense sheet is required to have only one expense because Odoo +does not allow creating more than one invoice for an expense sheet. The +*Split Expenses* action is provided to facilitate this operation. + +Configuration +============= + +To configure this module, you need to: + +1. Go to Expenses > Configuration > Settings +2. On Accounting group, set Employee Expense Journal. + +In case you need a different Journal for Intermediate Entries (Ensure +that this journal allows for the creation of 'entry' type moves): + +1. On Accounting group, set Employee Expense Intermediate Journal. + +Usage +===== + +To use this module, you need to: + +1. Go to Expenses and create a new one that is paid by Employee +2. Create report and Submit to Manager +3. Change Journal to Vendor Bills +4. Approve this Expense Sheet +5. Go to the Journal Entry clicking on the Smart Button +6. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, + journal, etc) +7. Confirm the invoice, or go to the Expense Sheet and Post Journal + Entries +8. Click on Pay and fill the payment for the Employee. Partial payments + are allowed +9. On the Journal Entries Smart Button, you could see a new one + intermediate entry +10. Finish the payment process if you partially pay by clicking again on + Pay + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Moduon + +Contributors +------------ + +- Eduardo de Miguel (`Moduon `__) +- Rafael Blasco (`Moduon `__) + +Other credits +------------- + +The development of this module has been financially supported by: + +- Cámara de Comercio Alemana para España + +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. + +.. |maintainer-Shide| image:: https://github.com/Shide.png?size=40px + :target: https://github.com/Shide + :alt: Shide +.. |maintainer-rafaelbn| image:: https://github.com/rafaelbn.png?size=40px + :target: https://github.com/rafaelbn + :alt: rafaelbn + +Current `maintainers `__: + +|maintainer-Shide| |maintainer-rafaelbn| + +This module is part of the `OCA/hr-expense `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_expense_employee_payment/__init__.py b/hr_expense_employee_payment/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/hr_expense_employee_payment/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_expense_employee_payment/__manifest__.py b/hr_expense_employee_payment/__manifest__.py new file mode 100644 index 000000000..24cb481b0 --- /dev/null +++ b/hr_expense_employee_payment/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +{ + "name": "Hr Expense - Employee Payment", + "summary": "Allow to pay employees for expense Vendor Bills", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "Human Resources/Expenses", + "website": "https://github.com/OCA/hr-expense", + "author": "Moduon, Odoo Community Association (OCA)", + "maintainers": ["Shide", "rafaelbn"], + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "hr_expense", + ], + "data": [ + "data/ir_actions_server.xml", + "views/res_config_settings_views.xml", + ], +} diff --git a/hr_expense_employee_payment/data/ir_actions_server.xml b/hr_expense_employee_payment/data/ir_actions_server.xml new file mode 100644 index 000000000..4c271ec38 --- /dev/null +++ b/hr_expense_employee_payment/data/ir_actions_server.xml @@ -0,0 +1,11 @@ + + + + Split Expenses + + + list,form + code + action = records._action_split_expenses() + + diff --git a/hr_expense_employee_payment/i18n/es.po b/hr_expense_employee_payment/i18n/es.po new file mode 100644 index 000000000..5643b7d67 --- /dev/null +++ b/hr_expense_employee_payment/i18n/es.po @@ -0,0 +1,88 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_employee_payment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-11-25 10:31+0000\n" +"PO-Revision-Date: 2025-11-25 11:33+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.5\n" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "%(expense_sheet_name)s | Expense conciliation" +msgstr "%(expense_sheet_name)s | Conciliación de gastos" + +#. module: hr_expense_employee_payment +#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense_sheet +msgid "Expense Report" +msgstr "Informe de gastos" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"Expense conciliation entry for '%(expense_sheet_name)s' expense sheet and " +"taking in consideration '%(vendor_move_names)s' moves." +msgstr "" +"Asiento de conciliación para el informe de gastos '%(expense_sheet_name)s' " +"teniendo en cuenta los movimientos'%(vendor_move_names)s'." + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"Only expense sheets in 'To Submit' or 'Submitted' state can be splitted. " +"Expense Sheet '%s' is in state '%s'." +msgstr "" +"Solo los informes de gastos con estados 'A enviar' o 'Enviado' pueden ser " +"divididos. El Informe de Gastos '%s' está en estado '%s'." + +#. module: hr_expense_employee_payment +#: model:ir.actions.server,name:hr_expense_employee_payment.hr_expense_sheet_split_action +msgid "Split Expenses" +msgstr "Dividir Gastos" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "Splitted Expense Sheets" +msgstr "Informes de Gastos divididos" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"This expense sheet has been created by splitting the expense sheet '%s'." +msgstr "" +"Este informe de gastos ha sido creado a partir de la división del informe " +"'%s'." + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"You cannot register a payments for Employees and Vendors at the same time" +msgstr "No puedes registrar pagos a Empleados y a Proveedores al mismo tiempo" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"You have multiple expense journals defined, please set a single expense " +"journal in the company settings to proceed with the payment for Employees " +"or pay one by one." +msgstr "" +"Tiene definidos varios diarios de gastos, por favor, establezca un único " +"diario de gastos en la configuración de la compañía para proceder al " +"pago de los empleados o pague uno por uno." diff --git a/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot b/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot new file mode 100644 index 000000000..aa1f4395f --- /dev/null +++ b/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot @@ -0,0 +1,77 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_employee_payment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-11-25 10:30+0000\n" +"PO-Revision-Date: 2025-11-25 10:30+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "%(expense_sheet_name)s | Expense conciliation" +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense_sheet +msgid "Expense Report" +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"Expense conciliation entry for '%(expense_sheet_name)s' expense sheet and " +"taking in consideration '%(vendor_move_names)s' moves." +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"Only expense sheets in 'To Submit' or 'Submitted' state can be splitted. " +"Expense Sheet '%s' is in state '%s'." +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.actions.server,name:hr_expense_employee_payment.hr_expense_sheet_split_action +msgid "Split Expenses" +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "Splitted Expense Sheets" +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"This expense sheet has been created by splitting the expense sheet '%s'." +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"You cannot register a payments for Employees and Vendors at the same time" +msgstr "" + +#. module: hr_expense_employee_payment +#. odoo-python +#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +msgid "" +"You have multiple expense journals defined, please set a single expense " +"journal in the company settings to proceed with the payment for Employees or" +" pay one by one." +msgstr "" diff --git a/hr_expense_employee_payment/models/__init__.py b/hr_expense_employee_payment/models/__init__.py new file mode 100644 index 000000000..92ea6a8d1 --- /dev/null +++ b/hr_expense_employee_payment/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_company +from . import res_config_settings +from . import hr_expense_sheet diff --git a/hr_expense_employee_payment/models/hr_expense_sheet.py b/hr_expense_employee_payment/models/hr_expense_sheet.py new file mode 100644 index 000000000..6b08ebcc0 --- /dev/null +++ b/hr_expense_employee_payment/models/hr_expense_sheet.py @@ -0,0 +1,332 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import Command, api, exceptions, models +from odoo.tools import float_compare, float_is_zero + + +class HrExpenseSheet(models.Model): + _inherit = "hr.expense.sheet" + + def _get_vendor_moves(self, for_employee=False): + """Get the vendor bills linked to selected expense sheets. + + :param for_employee: True: Bills for the employee. False: Bills for the vendor. + :return: A recordset of account.move matching vendor or employee bills. + """ + entry_moves = self.account_move_ids.filtered_domain( + [("move_type", "!=", "entry")] + ) + vendor_moves = entry_moves.filtered( + lambda move: move.partner_id + != move.expense_sheet_id.employee_id.sudo().work_contact_id + ) + if for_employee: + return entry_moves - vendor_moves + return vendor_moves + + def _get_vendor_payable_move_lines(self): + """Get the payable move lines from the vendor + bills linked to selected expense sheets.""" + vendor_bills = self._get_vendor_moves(for_employee=False) + return vendor_bills.mapped("line_ids").filtered( + lambda line: line.display_type == "payment_term" + and not float_is_zero( + line.amount_residual, precision_rounding=line.currency_id.rounding + ) + ) + + def _get_conciliation_moves(self, posted=False): + """Obtain the conciliation entries for selected expense sheets, if any. + + :param posted: If True, only posted moves are returned. + :return: A recordset of account.move matching conciliation entries. + """ + conciliation_moves = self.env["account.move"].browse() + valid_states = ["posted"] if posted else ["draft", "cancel", "posted"] + for sheet in self: + conciliation_moves |= sheet.account_move_ids.filtered_domain( + [ + ("move_type", "=", "entry"), + ("state", "in", valid_states), + ] + ) + return conciliation_moves + + def _get_conciliation_payable_move_lines(self, for_employee=False): + """Obtain the posted payable move lines from the conciliation entries + for selected expense sheets. + + :param for_employee: True: Lines for the employee. False: Lines for the vendor. + :return: A recordset of account.move.line matching conciliation payable lines. + """ + conciliation_payable_lines = self.env["account.move.line"].browse() + for conciliation_move in self._get_conciliation_moves(posted=True): + employee_partner = ( + conciliation_move.expense_sheet_id.employee_id.sudo().work_contact_id + ) + conciliation_payable_employee_lines = ( + conciliation_move.line_ids.filtered_domain( + [ + ("partner_id", "=", employee_partner.id), + ] + ) + ) + if for_employee: + conciliation_payable_lines |= conciliation_payable_employee_lines + else: + conciliation_payable_lines |= ( + conciliation_move.line_ids - conciliation_payable_employee_lines + ) + return conciliation_payable_lines + + def _prepare_conciliation_move_vals(self): + """Prepare the values for the conciliation entry move to + reconcile with the vendor bill and payment later.""" + result = [] + for sheet in self: + employee_p = sheet.employee_id.sudo().work_contact_id + company = sheet.company_id + exp_intermediate_journal = ( + company.expense_intermediate_journal_id + or company.expense_journal_id + or sheet.journal_id + ) + vendor_payable_move_lines = sheet._get_vendor_payable_move_lines() + result.append( + { + "company_id": company.id, + "journal_id": exp_intermediate_journal.id, + "ref": self.env._( + "%(expense_sheet_name)s | Expense conciliation", + expense_sheet_name=self.name, + ), + "narration": self.env._( + "Expense conciliation entry for " + "'%(expense_sheet_name)s' expense sheet " + "and taking in consideration '%(vendor_move_names)s' moves.", + expense_sheet_name=sheet.name, + vendor_move_names=", ".join( + vendor_payable_move_lines.mapped("move_id.name") + ), + ), + "move_type": "entry", + "date": sheet.accounting_date, + "currency_id": sheet.currency_id.id, + "expense_sheet_id": sheet.id, + "line_ids": [ + Command.create( + { + "name": sheet.name, + "partner_id": employee_p.id, + "account_id": employee_p.property_account_payable_id.id, + "debit": 0.0, + "credit": sheet.amount_residual, + } + ), + *[ + Command.create( + { + "name": f"{payable_move_line.move_id.name} " + f"({payable_move_line.move_id.ref})", + "partner_id": payable_move_line.partner_id.id, + "account_id": payable_move_line.account_id.id, + "debit": -payable_move_line.amount_residual, + "credit": 0.0, + } + ) + for payable_move_line in vendor_payable_move_lines + ], + ], + } + ) + + return result + + def _reconcile_conciliation_move_with_vendor_bills(self): + """Reconcile the Conciliation Entry and the Vendor Bill.""" + for sheet in self: + # Get the conciliation entry and post it if needed + conciliation_move = sheet._get_conciliation_moves(posted=False) + if not conciliation_move: + continue + if conciliation_move.state == "draft": + conciliation_move.sudo().action_post() + # Select the payable vendor lines to reconcile + conciliation_vendor_payable_move_lines = ( + sheet._get_conciliation_payable_move_lines(for_employee=False) + ) + vendor_payable_move_lines = sheet._get_vendor_payable_move_lines() + lines_to_reconcile = ( + conciliation_vendor_payable_move_lines | vendor_payable_move_lines + ) + lines_to_reconcile.reconcile() + + def _create_conciliation_moves(self): + """Create an intermediate entry to reconcile with + the vendor bills and payment later.""" + # Get the payable vendor bill lines to reconcile + conciliation_moves = self._get_conciliation_moves(posted=True) + for remaining_sheet in self - conciliation_moves.mapped("expense_sheet_id"): + remaining_conciliation_move = ( + self.env["account.move"] + .sudo() + .create(remaining_sheet._prepare_conciliation_move_vals()) + ) + remaining_sheet._reconcile_conciliation_move_with_vendor_bills() + conciliation_moves |= remaining_conciliation_move + return conciliation_moves + + def action_register_payment(self): + """Override to create the payment for the Conciliation entry to the Employee and + create the conciliation entry if needed.""" + # Check if there are Vendor and Employee Bills at the same time + employee_bills = self._get_vendor_moves(for_employee=True) + vendor_bills = self._get_vendor_moves(for_employee=False) + if vendor_bills and employee_bills: + raise exceptions.UserError( + self.env._( + "You cannot register a payments for Employees and Vendors " + "at the same time" + ) + ) + # If payments are only for employee bills, use the normal flow + if employee_bills: + return super().action_register_payment() + # At this point, we can assume that Vendor bills are not for the employee + # Ensure all conciliation entries are created, posted and conciled + conciliation_moves = self._get_conciliation_moves(posted=False) + conciliation_moves.filtered(lambda move: move.state == "draft").mapped( + "expense_sheet_id" + )._reconcile_conciliation_move_with_vendor_bills() + # Remaining sheets to process that are not in the conciliation moves + sheets_to_create_conciliation_moves = self - conciliation_moves.mapped( + "expense_sheet_id" + ) + if sheets_to_create_conciliation_moves: + conciliation_moves |= ( + sheets_to_create_conciliation_moves._create_conciliation_moves() + ) + # All conciliation entries are created, so continue with the payment process + # Choose company Expense Journal first, if not set, use the sheet Journal + expense_journal = self.company_id.expense_journal_id or self.journal_id + if len(expense_journal) > 1: + raise exceptions.UserError( + self.env._( + "You have multiple expense journals defined, please set a single " + "expense journal in the company settings to proceed with the " + "payment for Employees or pay one by one." + ) + ) + # Check if all payments belongs to the same employee to set default bank account + conciliation_emp_pay_move_lines = self._get_conciliation_payable_move_lines( + for_employee=True + ) + default_partner_bank_id = None + if len(conciliation_emp_pay_move_lines.mapped("partner_id")) == 1: + employee_partner = conciliation_emp_pay_move_lines.mapped("partner_id")[0] + default_partner_bank_id = employee_partner.bank_ids[:1].id + # Return the action to register payment for the conciliation entries + # Group payment if all conciliation entries belongs to the same employee + return conciliation_emp_pay_move_lines.action_register_payment( + ctx={ + "default_partner_bank_id": default_partner_bank_id, + "default_journal_id": expense_journal.id, + "default_group_payment": bool(default_partner_bank_id), + } + ) + + @api.depends("account_move_ids.line_ids.reconciled") + def _compute_from_account_move_ids(self): + res = super()._compute_from_account_move_ids() + for sheet in self: + # Only manage paid by Employee mode + if sheet.payment_mode != "own_account": + continue + # If all bills belongs to the employee, we don't have nothing to do + employee_bills = sheet._get_vendor_moves(for_employee=True) + if employee_bills: + continue + # At this point, we can assume that Vendor bills are not for the employee + # Check the payment status for the employee on the conciliation entry + employee_conciliation_move_lines = ( + sheet._get_conciliation_payable_move_lines(for_employee=True) + ) + if not employee_conciliation_move_lines: + # No conciliation entry created yet or is not valid + sheet.payment_state = "not_paid" + sheet.amount_residual = sheet.total_amount + continue + + if all(employee_conciliation_move_lines.mapped("reconciled")): + sheet.payment_state = "paid" + sheet.amount_residual = 0.0 + else: + not_reconciled_lines = employee_conciliation_move_lines.filtered( + lambda line: not line.reconciled + ) + sheet.amount_residual = -sum( + not_reconciled_lines.mapped("amount_residual") + ) + if ( + float_compare( + sheet.amount_residual, + -sum(not_reconciled_lines.mapped("balance")), + precision_rounding=sheet.currency_id.rounding, + ) + == 0 + ): + sheet.payment_state = "not_paid" + else: + sheet.payment_state = "partial" + return res + + def _action_split_expenses(self): + """Action to split expense sheet into multiple sheets + with one expense line each.""" + result_sheets = self.env["hr.expense.sheet"].browse() + for sheet in self: + if sheet.state not in ("draft", "submit"): + raise exceptions.UserError( + self.env._( + "Only expense sheets in 'To Submit' or 'Submitted' state " + "can be splitted. Expense Sheet '%s' is in state '%s'.", + sheet.name, + sheet.state, + ) + ) + if len(sheet.expense_line_ids) <= 1: + result_sheets |= sheet + continue + for line in sheet.expense_line_ids: + new_sheet = sheet.copy(default={"name": f"{sheet.name} - {line.name}"}) + new_sheet.message_post( + body=self.env._( + "This expense sheet has been created by splitting " + "the expense sheet '%s'.", + sheet.name, + ) + ) + line.sheet_id = new_sheet + if sheet.state != "draft": # Submitted + new_sheet._do_submit() + result_sheets |= new_sheet + try: + sheet.sudo().unlink() + except exceptions.UserError: + # In case the user cannot unlink the sheet, refuse with a reason + sheet._do_refuse( + self.env._( + "This expense sheet has been splitted into multiple sheets." + ) + ) + action = self.env["ir.actions.actions"]._for_xml_id( + "hr_expense.action_hr_expense_sheet_all" + ) + action.update( + { + "domain": [("id", "in", result_sheets.ids)], + "name": self.env._("Splitted Expense Sheets"), + } + ) + return action diff --git a/hr_expense_employee_payment/models/res_company.py b/hr_expense_employee_payment/models/res_company.py new file mode 100644 index 000000000..39c67dd24 --- /dev/null +++ b/hr_expense_employee_payment/models/res_company.py @@ -0,0 +1,17 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + expense_intermediate_journal_id = fields.Many2one( + comodel_name="account.journal", + string="Intermediate Expense Journal", + check_company=True, + domain=[("type", "=", "general")], + help="Journal used to record intermediate entries for employee expenses.\n" + "If not set, Expense Journal will be used instead.", + ) diff --git a/hr_expense_employee_payment/models/res_config_settings.py b/hr_expense_employee_payment/models/res_config_settings.py new file mode 100644 index 000000000..08d38edda --- /dev/null +++ b/hr_expense_employee_payment/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + expense_intermediate_journal_id = fields.Many2one( + comodel_name="account.journal", + related="company_id.expense_intermediate_journal_id", + readonly=False, + check_company=True, + domain="[('type', '=', 'general')]", + ) diff --git a/hr_expense_employee_payment/pyproject.toml b/hr_expense_employee_payment/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_employee_payment/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_employee_payment/readme/CONFIGURE.md b/hr_expense_employee_payment/readme/CONFIGURE.md new file mode 100644 index 000000000..46b0367e8 --- /dev/null +++ b/hr_expense_employee_payment/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +To configure this module, you need to: + +1. Go to Expenses > Configuration > Settings +1. On Accounting group, set Employee Expense Journal. + +In case you need a different Journal for Intermediate Entries (Ensure that this journal allows for the creation of 'entry' type moves): + +1. On Accounting group, set Employee Expense Intermediate Journal. diff --git a/hr_expense_employee_payment/readme/CONTEXT.md b/hr_expense_employee_payment/readme/CONTEXT.md new file mode 100644 index 000000000..c773323eb --- /dev/null +++ b/hr_expense_employee_payment/readme/CONTEXT.md @@ -0,0 +1,7 @@ +This module has been created to allow payment to the employee when supplier invoices are recorded in expenses. + +It will be useful for you if the legislation in your country doesn't allow to create vendor bills to the employees. + +Each expense sheet is required to have only one expense because +Odoo does not allow creating more than one invoice for an expense sheet. +The *Split Expenses* action is provided to facilitate this operation. diff --git a/hr_expense_employee_payment/readme/CONTRIBUTORS.md b/hr_expense_employee_payment/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..0ca1be35b --- /dev/null +++ b/hr_expense_employee_payment/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Eduardo de Miguel ([Moduon](https://www.moduon.team/)) +- Rafael Blasco ([Moduon](https://www.moduon.team/)) diff --git a/hr_expense_employee_payment/readme/CREDITS.md b/hr_expense_employee_payment/readme/CREDITS.md new file mode 100644 index 000000000..f39961fbd --- /dev/null +++ b/hr_expense_employee_payment/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Cámara de Comercio Alemana para España diff --git a/hr_expense_employee_payment/readme/DESCRIPTION.md b/hr_expense_employee_payment/readme/DESCRIPTION.md new file mode 100644 index 000000000..ea1f26353 --- /dev/null +++ b/hr_expense_employee_payment/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This module extends the expense functionality so that expenses sheets paid by employees, +which are actual Vendor Bills, can be paid to the employee. + +To do this, an intermediate journal entry is created that transfers the debt +from the supplier to the employee and also modifies the payment wizard so that +the employee receives the payment. Partial payments are also compatible. diff --git a/hr_expense_employee_payment/readme/USAGE.md b/hr_expense_employee_payment/readme/USAGE.md new file mode 100644 index 000000000..924a7c830 --- /dev/null +++ b/hr_expense_employee_payment/readme/USAGE.md @@ -0,0 +1,12 @@ +To use this module, you need to: + +1. Go to Expenses and create a new one that is paid by Employee +1. Create report and Submit to Manager +1. Change Journal to Vendor Bills +1. Approve this Expense Sheet +1. Go to the Journal Entry clicking on the Smart Button +1. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, journal, etc) +1. Confirm the invoice, or go to the Expense Sheet and Post Journal Entries +1. Click on Pay and fill the payment for the Employee. Partial payments are allowed +1. On the Journal Entries Smart Button, you could see a new one intermediate entry +1. Finish the payment process if you partially pay by clicking again on Pay diff --git a/hr_expense_employee_payment/static/description/icon.png b/hr_expense_employee_payment/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/hr_expense_employee_payment/static/description/index.html b/hr_expense_employee_payment/static/description/index.html new file mode 100644 index 000000000..ca34dce84 --- /dev/null +++ b/hr_expense_employee_payment/static/description/index.html @@ -0,0 +1,493 @@ + + + + + +Hr Expense - Employee Payment + + + +
+

Hr Expense - Employee Payment

+ + +

Alpha License: LGPL-3 OCA/hr-expense Translate me on Weblate Try me on Runboat

+

This module extends the expense functionality so that expenses sheets +paid by employees, which are actual Vendor Bills, can be paid to the +employee.

+

To do this, an intermediate journal entry is created that transfers the +debt from the supplier to the employee and also modifies the payment +wizard so that the employee receives the payment. Partial payments are +also compatible.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Use Cases / Context

+

This module has been created to allow payment to the employee when +supplier invoices are recorded in expenses.

+

It will be useful for you if the legislation in your country doesn’t +allow to create vendor bills to the employees.

+

Each expense sheet is required to have only one expense because Odoo +does not allow creating more than one invoice for an expense sheet. The +Split Expenses action is provided to facilitate this operation.

+
+
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Go to Expenses > Configuration > Settings
  2. +
  3. On Accounting group, set Employee Expense Journal.
  4. +
+

In case you need a different Journal for Intermediate Entries (Ensure +that this journal allows for the creation of ‘entry’ type moves):

+
    +
  1. On Accounting group, set Employee Expense Intermediate Journal.
  2. +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to Expenses and create a new one that is paid by Employee
  2. +
  3. Create report and Submit to Manager
  4. +
  5. Change Journal to Vendor Bills
  6. +
  7. Approve this Expense Sheet
  8. +
  9. Go to the Journal Entry clicking on the Smart Button
  10. +
  11. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, +journal, etc)
  12. +
  13. Confirm the invoice, or go to the Expense Sheet and Post Journal +Entries
  14. +
  15. Click on Pay and fill the payment for the Employee. Partial payments +are allowed
  16. +
  17. On the Journal Entries Smart Button, you could see a new one +intermediate entry
  18. +
  19. Finish the payment process if you partially pay by clicking again on +Pay
  20. +
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Moduon
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Cámara de Comercio Alemana para España
  • +
+
+
+

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.

+

Current maintainers:

+

Shide rafaelbn

+

This module is part of the OCA/hr-expense project on GitHub.

+

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

+
+
+
+ + diff --git a/hr_expense_employee_payment/tests/__init__.py b/hr_expense_employee_payment/tests/__init__.py new file mode 100644 index 000000000..3f54611e2 --- /dev/null +++ b/hr_expense_employee_payment/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_expense_sheet diff --git a/hr_expense_employee_payment/tests/test_hr_expense_sheet.py b/hr_expense_employee_payment/tests/test_hr_expense_sheet.py new file mode 100644 index 000000000..619f2aaed --- /dev/null +++ b/hr_expense_employee_payment/tests/test_hr_expense_sheet.py @@ -0,0 +1,316 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + + +from odoo import Command, exceptions +from odoo.tests import Form, tagged + +from odoo.addons.hr_expense.tests.common import TestExpenseCommon + + +@tagged("-at_install", "post_install") +class TestHrExpenseEmployeePayment(TestExpenseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.exp_emp_partner = cls.expense_employee.work_contact_id + cls.exp_emp_partner_bank_account = cls.env["res.partner.bank"].create( + { + "acc_number": "1112223334", + "partner_id": cls.exp_emp_partner.id, + "acc_type": "bank", + } + ) + + def _force_approve_with_draft_move(self, expense_sheet): + """Force the approval of an expense and left + the related move in draft state. + This function allows to be compatible with other hr-expense modules.""" + if not expense_sheet.account_move_ids: + expense_sheet.action_sheet_move_post() + move = expense_sheet.account_move_ids + if move.state == "posted": + move.button_draft() + + def test_two_payments_one_expense_sheet(self): + """Test paying one expense sheet with two payments""" + # Create sheet and vendor bill + expense_sheet = self.create_expense_report( + { + "name": "SHEET 2p 1e", + "journal_id": self.company_data["default_journal_purchase"].id, + "expense_line_ids": [ + Command.create( + { + "name": "EXPENSE 2p 1e", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 100.00, + } + ) + ], + } + ) + expense_sheet.action_submit_sheet() + expense_sheet.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet) + # Select current Employee draft Bill and change to be a Vendor Bill + vendor_bill = expense_sheet._get_vendor_moves(for_employee=True) + vendor_bill.write({"partner_id": self.partner_a.id, "ref": "VB_01"}) + vendor_bill = expense_sheet._get_vendor_moves(for_employee=False) + expense_sheet.action_sheet_move_post() + self.assertTrue(vendor_bill) + # Generate Payment Wizard + action_data = expense_sheet.action_register_payment() + # Check conciliation move exists + conciliation_move = expense_sheet._get_conciliation_moves(posted=True) + self.assertTrue(conciliation_move) + # Check that the vendor payable move line is reconciled + vendor_payable_move_line = expense_sheet._get_conciliation_payable_move_lines( + for_employee=False + ) + self.assertEqual(vendor_payable_move_line.reconciled, True) + # Check that the employee payable move line is not reconciled + employee_payable_move_line = expense_sheet._get_conciliation_payable_move_lines( + for_employee=True + ) + self.assertEqual(employee_payable_move_line.reconciled, False) + # Check payment state on sheet is not paid + self.assertEqual(expense_sheet.payment_state, "not_paid") + # Pay 1 + with Form( + self.env["account.payment.register"].with_context(**action_data["context"]) + ) as pay_form: + self.assertEqual( + pay_form.partner_bank_id, self.exp_emp_partner_bank_account + ) + pay_form.amount = 60.00 + payment = pay_form.save() + payment.action_create_payments() + self.assertEqual(expense_sheet.payment_state, "partial") + self.assertEqual(expense_sheet.amount_residual, 40.00) + # Pay 2 + action_data = expense_sheet.action_register_payment() + with Form( + self.env["account.payment.register"].with_context(**action_data["context"]) + ) as pay_form: + self.assertEqual( + pay_form.partner_bank_id, self.exp_emp_partner_bank_account + ) + self.assertEqual(pay_form.amount, 40.00) + payment = pay_form.save() + payment.action_create_payments() + self.assertEqual(expense_sheet.payment_state, "paid") + self.assertEqual(expense_sheet.amount_residual, 0.00) + # Check that the employee payable move line is now reconciled + self.assertEqual(employee_payable_move_line.reconciled, True) + # Check that the vendor payable move line is still reconciled + self.assertEqual(vendor_payable_move_line.reconciled, True) + + def test_one_payment_two_expense_sheets(self): + """Test paying two expense sheets with one payment""" + # Create sheet 1 and vendor bill + expense_sheet1 = self.create_expense_report( + { + "name": "SHEET1 1p 2e", + "journal_id": self.company_data["default_journal_purchase"].id, + "expense_line_ids": [ + Command.create( + { + "name": "EXPENSE1 1p 2e", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 100.00, + } + ) + ], + } + ) + expense_sheet1.action_submit_sheet() + expense_sheet1.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet1) + expense_sheet1._get_vendor_moves(for_employee=True).write( + {"partner_id": self.partner_a.id, "ref": "VB_01"} + ) + expense_sheet1.action_sheet_move_post() + # Create sheet 2 and vendor bill + expense_sheet2 = self.create_expense_report( + { + "name": "SHEET2 1p 2e", + "journal_id": self.company_data["default_journal_purchase"].id, + "expense_line_ids": [ + Command.create( + { + "name": "EXPENSE2 1p 2e", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 200.00, + } + ) + ], + } + ) + expense_sheet2.action_submit_sheet() + expense_sheet2.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet2) + expense_sheet2._get_vendor_moves(for_employee=True).write( + {"partner_id": self.partner_a.id, "ref": "VB_02"} + ) + expense_sheet2.action_sheet_move_post() + # Pay both sheets at once + expense_sheets = expense_sheet1 | expense_sheet2 + action_data = expense_sheets.action_register_payment() + with Form( + self.env["account.payment.register"].with_context(**action_data["context"]) + ) as pay_form: + self.assertEqual( + pay_form.partner_bank_id, self.exp_emp_partner_bank_account + ) + self.assertTrue(pay_form.group_payment) + payment = pay_form.save() + payment.action_create_payments() + self.assertEqual(expense_sheet1.payment_state, "paid") + self.assertEqual(expense_sheet2.payment_state, "paid") + # Check that the employee payable move lines are now reconciled + employee_payable_move_lines = ( + expense_sheets._get_conciliation_payable_move_lines(for_employee=True) + ) + self.assertTrue(employee_payable_move_lines.mapped("reconciled")) + # Check that the vendor payable move line is still reconciled + vendor_payable_move_lines = expense_sheets._get_conciliation_payable_move_lines( + for_employee=False + ) + self.assertTrue(vendor_payable_move_lines.mapped("reconciled")) + + def test_unable_to_pay_employee_and_vendor_bill_at_the_same_time(self): + """Test trying to pay a Vendor Expense Sheet and an + Employee Expense Sheet at the same time""" + # Create sheet 1 and vendor bill + expense_sheet1 = self.create_expense_report( + { + "name": "SHEET1 1p 2e error", + "journal_id": self.company_data["default_journal_purchase"].id, + "expense_line_ids": [ + Command.create( + { + "name": "EXPENSE1 1p 2e error", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 100.00, + } + ) + ], + } + ) + expense_sheet1.action_submit_sheet() + expense_sheet1.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet1) + expense_sheet1._get_vendor_moves(for_employee=True).write( + {"partner_id": self.partner_a.id, "ref": "VB_01"} + ) + expense_sheet1.action_sheet_move_post() + # Create sheet 2 and employee bill + expense_sheet2 = self.create_expense_report( + { + "name": "SHEET2 1p 2e error", + "journal_id": self.company_data["default_journal_purchase"].id, + "expense_line_ids": [ + Command.create( + { + "name": "EXPENSE2 1p 2e error", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 200.00, + } + ) + ], + } + ) + expense_sheet2.action_submit_sheet() + expense_sheet2.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet2) + expense_sheet2.action_sheet_move_post() + # Pay both sheets at once + expense_sheets = expense_sheet1 | expense_sheet2 + with self.assertRaisesRegex( + exceptions.UserError, "cannot register a payments for Employees and Vendors" + ): + expense_sheets.action_register_payment() + + def test_split_expense_sheet(self): + """Test splitting an expense sheet with multiple lines into + separate expense sheets with one line each""" + # Create sheet with two lines + expense_sheet = self.create_expense_report( + { + "name": "SPLIT SHEET", + "expense_line_ids": [ + Command.create( + { + "name": "SPLIT EXPENSE 1", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 100.00, + } + ), + Command.create( + { + "name": "SPLIT EXPENSE 2", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 200.00, + } + ), + ], + } + ) + expense_sheet.action_submit_sheet() + self.assertEqual(expense_sheet.state, "submit") + # Split expense sheet + action = expense_sheet._action_split_expenses() + splitted_sheets = self.env["hr.expense.sheet"].search(action["domain"]) + self.assertEqual(len(splitted_sheets), 2) + # State is preserved + self.assertEqual(splitted_sheets.mapped("state"), ["submit", "submit"]) + + def test_cannot_split_expense_sheet(self): + """Test can't splitting an expense sheet if state is not allowed""" + # Create sheet with two lines + expense_sheet = self.create_expense_report( + { + "name": "SPLIT SHEET", + "expense_line_ids": [ + Command.create( + { + "name": "SPLIT EXPENSE 1", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 100.00, + } + ), + Command.create( + { + "name": "SPLIT EXPENSE 2", + "payment_mode": "own_account", + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount": 200.00, + } + ), + ], + } + ) + expense_sheet.action_submit_sheet() + expense_sheet.action_approve_expense_sheets() + self._force_approve_with_draft_move(expense_sheet) + # Split the sheet with raises + with self.assertRaisesRegex(exceptions.UserError, "can be splitted"): + expense_sheet._action_split_expenses() diff --git a/hr_expense_employee_payment/views/res_config_settings_views.xml b/hr_expense_employee_payment/views/res_config_settings_views.xml new file mode 100644 index 000000000..b56547731 --- /dev/null +++ b/hr_expense_employee_payment/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ + + + + res.config.settings.view.form.inherit.hr.expense.intermediate + res.config.settings + + + + + + + + + + From 3f783cbbbb11922cc84b154224f33b2c5d739bfa Mon Sep 17 00:00:00 2001 From: Eduardo de Miguel Date: Thu, 11 Jun 2026 10:16:46 +0200 Subject: [PATCH 2/3] [FIX] hr_expense_employee_payment: Conciliation Move on Expense Adding this field prepares this module to an easy migration process --- .../models/__init__.py | 1 + .../models/hr_expense.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 hr_expense_employee_payment/models/hr_expense.py diff --git a/hr_expense_employee_payment/models/__init__.py b/hr_expense_employee_payment/models/__init__.py index 92ea6a8d1..f2da9b513 100644 --- a/hr_expense_employee_payment/models/__init__.py +++ b/hr_expense_employee_payment/models/__init__.py @@ -1,3 +1,4 @@ from . import res_company from . import res_config_settings from . import hr_expense_sheet +from . import hr_expense diff --git a/hr_expense_employee_payment/models/hr_expense.py b/hr_expense_employee_payment/models/hr_expense.py new file mode 100644 index 000000000..9e5230741 --- /dev/null +++ b/hr_expense_employee_payment/models/hr_expense.py @@ -0,0 +1,27 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import api, fields, models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + conciliation_move_id = fields.Many2one( + comodel_name="account.move", + string="Conciliation Entry", + compute="_compute_conciliation_move_id", + store=True, + help="Intermediate entry created to reconcile with the vendor " + "bill and payment later.", + ) + + @api.depends("sheet_id.account_move_ids.move_type") + def _compute_conciliation_move_id(self): + for expense in self: + if not expense.sheet_id.account_move_ids: + continue + expense_conciliation_moves = expense.sheet_id._get_conciliation_moves( + posted=False + ) + expense.conciliation_move_id = expense_conciliation_moves[:1] From eae1a5a0f2a756ada2e28012caf9c6489efa398b Mon Sep 17 00:00:00 2001 From: Eduardo de Miguel Date: Thu, 11 Jun 2026 10:24:07 +0200 Subject: [PATCH 3/3] [MIG] hr_expense_employee_payment: Migration to 19.0 --- hr_expense_employee_payment/README.rst | 59 ++-- hr_expense_employee_payment/__manifest__.py | 4 +- .../data/ir_actions_server.xml | 11 - hr_expense_employee_payment/i18n/es.po | 124 ++++--- .../i18n/hr_expense_employee_payment.pot | 89 +++-- .../models/__init__.py | 2 +- .../models/account_move.py | 31 ++ .../models/hr_expense.py | 193 +++++++++- .../models/hr_expense_sheet.py | 332 ------------------ hr_expense_employee_payment/readme/CONTEXT.md | 4 - hr_expense_employee_payment/readme/CREDITS.md | 3 - .../readme/DESCRIPTION.md | 4 +- hr_expense_employee_payment/readme/USAGE.md | 16 +- .../static/description/index.html | 73 ++-- hr_expense_employee_payment/tests/__init__.py | 2 +- .../tests/test_hr_expense.py | 145 ++++++++ .../tests/test_hr_expense_sheet.py | 316 ----------------- .../views/hr_expense_views.xml | 35 ++ 18 files changed, 606 insertions(+), 837 deletions(-) delete mode 100644 hr_expense_employee_payment/data/ir_actions_server.xml create mode 100644 hr_expense_employee_payment/models/account_move.py delete mode 100644 hr_expense_employee_payment/models/hr_expense_sheet.py delete mode 100644 hr_expense_employee_payment/readme/CREDITS.md create mode 100644 hr_expense_employee_payment/tests/test_hr_expense.py delete mode 100644 hr_expense_employee_payment/tests/test_hr_expense_sheet.py create mode 100644 hr_expense_employee_payment/views/hr_expense_views.xml diff --git a/hr_expense_employee_payment/README.rst b/hr_expense_employee_payment/README.rst index 504de61a7..4ba658db2 100644 --- a/hr_expense_employee_payment/README.rst +++ b/hr_expense_employee_payment/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ============================= Hr Expense - Employee Payment ============================= @@ -13,23 +17,23 @@ Hr Expense - Employee Payment .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status :alt: Alpha -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--expense-lightgray.png?logo=github - :target: https://github.com/OCA/hr-expense/tree/18.0/hr_expense_employee_payment + :target: https://github.com/OCA/hr-expense/tree/19.0/hr_expense_employee_payment :alt: OCA/hr-expense .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/hr-expense-18-0/hr-expense-18-0-hr_expense_employee_payment + :target: https://translation.odoo-community.org/projects/hr-expense-19-0/hr-expense-19-0-hr_expense_employee_payment :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/hr-expense&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr-expense&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| -This module extends the expense functionality so that expenses sheets -paid by employees, which are actual Vendor Bills, can be paid to the +This module extends the expense functionality so that expenses paid by +employees, which are actual **Vendor Bills**, can be paid to the employee. To do this, an intermediate journal entry is created that transfers the @@ -56,10 +60,6 @@ supplier invoices are recorded in expenses. It will be useful for you if the legislation in your country doesn't allow to create vendor bills to the employees. -Each expense sheet is required to have only one expense because Odoo -does not allow creating more than one invoice for an expense sheet. The -*Split Expenses* action is provided to facilitate this operation. - Configuration ============= @@ -78,20 +78,24 @@ Usage To use this module, you need to: -1. Go to Expenses and create a new one that is paid by Employee -2. Create report and Submit to Manager -3. Change Journal to Vendor Bills -4. Approve this Expense Sheet -5. Go to the Journal Entry clicking on the Smart Button -6. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, +1. Go to Expenses > Configuration > Settings and set *Employee Expense + Journal* and *Employee Expense Intermediate Journal* +2. Go to Expenses and create a new one that is paid by Employee (to + reimburse) +3. Submit to Manager +4. Click on Post Journal Entries +5. Select a Journal to register the real Vendor Bill on the Wizard +6. Click on Reset to Draft +7. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, journal, etc) -7. Confirm the invoice, or go to the Expense Sheet and Post Journal - Entries -8. Click on Pay and fill the payment for the Employee. Partial payments +8. Confirm the Bill +9. Click on Pay and fill the payment for the Employee. Partial payments are allowed -9. On the Journal Entries Smart Button, you could see a new one - intermediate entry -10. Finish the payment process if you partially pay by clicking again on +10. If you make a partial payment or cancel the payment over the Bill, + you can continue the Payment on the Expense +11. On the Expense, you could see a new one intermediate entry on the + Smart Button +12. Finish the payment process if you partially pay by clicking again on Pay Bug Tracker @@ -100,7 +104,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -118,13 +122,6 @@ Contributors - Eduardo de Miguel (`Moduon `__) - Rafael Blasco (`Moduon `__) -Other credits -------------- - -The development of this module has been financially supported by: - -- Cámara de Comercio Alemana para España - Maintainers ----------- @@ -149,6 +146,6 @@ Current `maintainers `__: |maintainer-Shide| |maintainer-rafaelbn| -This module is part of the `OCA/hr-expense `_ project on GitHub. +This module is part of the `OCA/hr-expense `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_expense_employee_payment/__manifest__.py b/hr_expense_employee_payment/__manifest__.py index 24cb481b0..e019c3276 100644 --- a/hr_expense_employee_payment/__manifest__.py +++ b/hr_expense_employee_payment/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Hr Expense - Employee Payment", "summary": "Allow to pay employees for expense Vendor Bills", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "development_status": "Alpha", "category": "Human Resources/Expenses", "website": "https://github.com/OCA/hr-expense", @@ -17,7 +17,7 @@ "hr_expense", ], "data": [ - "data/ir_actions_server.xml", "views/res_config_settings_views.xml", + "views/hr_expense_views.xml", ], } diff --git a/hr_expense_employee_payment/data/ir_actions_server.xml b/hr_expense_employee_payment/data/ir_actions_server.xml deleted file mode 100644 index 4c271ec38..000000000 --- a/hr_expense_employee_payment/data/ir_actions_server.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - Split Expenses - - - list,form - code - action = records._action_split_expenses() - - diff --git a/hr_expense_employee_payment/i18n/es.po b/hr_expense_employee_payment/i18n/es.po index 5643b7d67..22de5a1fe 100644 --- a/hr_expense_employee_payment/i18n/es.po +++ b/hr_expense_employee_payment/i18n/es.po @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 18.0\n" +"Project-Id-Version: Odoo Server 19.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-25 10:31+0000\n" -"PO-Revision-Date: 2025-11-25 11:33+0100\n" +"POT-Creation-Date: 2026-06-10 09:58+0000\n" +"PO-Revision-Date: 2026-06-10 12:05+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: es\n" @@ -15,74 +15,100 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 3.5\n" +"X-Generator: Poedit 3.9\n" #. module: hr_expense_employee_payment #. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "%(expense_sheet_name)s | Expense conciliation" -msgstr "%(expense_sheet_name)s | Conciliación de gastos" +#: code:addons/hr_expense_employee_payment/models/hr_expense.py:0 +msgid "%(expense_name)s | Expense conciliation" +msgstr "%(expense_name)s | Conciliación de gastos" #. module: hr_expense_employee_payment -#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense_sheet -msgid "Expense Report" -msgstr "Informe de gastos" - -#. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form msgid "" -"Expense conciliation entry for '%(expense_sheet_name)s' expense sheet and " -"taking in consideration '%(vendor_move_names)s' moves." +"Accounting journal for intermediate entries to allow pay real Bills to " +"employees. This journal must allow the creation of moves of type 'entry'." msgstr "" -"Asiento de conciliación para el informe de gastos '%(expense_sheet_name)s' " -"teniendo en cuenta los movimientos'%(vendor_move_names)s'." +"Diario para los asientos intermedios que permiten pagar facturas reales " +"a los empleados. Este diario debe permitir la creación de movimientos de " +"tipo 'entry'." #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "" -"Only expense sheets in 'To Submit' or 'Submitted' state can be splitted. " -"Expense Sheet '%s' is in state '%s'." -msgstr "" -"Solo los informes de gastos con estados 'A enviar' o 'Enviado' pueden ser " -"divididos. El Informe de Gastos '%s' está en estado '%s'." +#: model:ir.model,name:hr_expense_employee_payment.model_res_company +msgid "Companies" +msgstr "Compañías" #. module: hr_expense_employee_payment -#: model:ir.actions.server,name:hr_expense_employee_payment.hr_expense_sheet_split_action -msgid "Split Expenses" -msgstr "Dividir Gastos" +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__conciliation_move_id +msgid "Conciliation Entry" +msgstr "Asiento de conciliación" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "Splitted Expense Sheets" -msgstr "Informes de Gastos divididos" +#: model:ir.model,name:hr_expense_employee_payment.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: hr_expense_employee_payment +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__display_name +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__display_name +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__display_name +msgid "Display Name" +msgstr "Nombre para mostrar" + +#. module: hr_expense_employee_payment +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form +msgid "Employee Expense Intermediate Journal" +msgstr "Diario Intermedio de Gastos del Empleado" + +#. module: hr_expense_employee_payment +#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense +msgid "Expense" +msgstr "Gasto" #. module: hr_expense_employee_payment #. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: code:addons/hr_expense_employee_payment/models/hr_expense.py:0 msgid "" -"This expense sheet has been created by splitting the expense sheet '%s'." +"Expense conciliation entry for '%(expense_name)s' expense and taking in " +"consideration '%(vendor_move_names)s' moves." msgstr "" -"Este informe de gastos ha sido creado a partir de la división del informe " -"'%s'." +"Asiento de conciliación para los gastos '%(expense_name)s' teniendo en " +"cuenta los movimientos '%(vendor_move_names)s'." #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__expense_intermediate_journal_id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__expense_intermediate_journal_id +msgid "Intermediate Expense Journal" +msgstr "Diario Intermedio de Gastos" + +#. module: hr_expense_employee_payment +#: model:ir.model.fields,help:hr_expense_employee_payment.field_hr_expense__conciliation_move_id msgid "" -"You cannot register a payments for Employees and Vendors at the same time" -msgstr "No puedes registrar pagos a Empleados y a Proveedores al mismo tiempo" +"Intermediate entry created to reconcile with the vendor bill and payment " +"later." +msgstr "" +"Asiento intermedio creado para conciliar la factura del Vendedor y el " +"pago a posteriori." #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: model:ir.model.fields,help:hr_expense_employee_payment.field_res_company__expense_intermediate_journal_id +#: model:ir.model.fields,help:hr_expense_employee_payment.field_res_config_settings__expense_intermediate_journal_id msgid "" -"You have multiple expense journals defined, please set a single expense " -"journal in the company settings to proceed with the payment for Employees " -"or pay one by one." +"Journal used to record intermediate entries for employee expenses.\n" +"If not set, Expense Journal will be used instead." msgstr "" -"Tiene definidos varios diarios de gastos, por favor, establezca un único " -"diario de gastos en la configuración de la compañía para proceder al " -"pago de los empleados o pague uno por uno." +"Diario utilizado para guardar los asientos intermedios para los gastos e " +"empleados.\n" +"Si no está establecido, se utilizará el diario de gastos." + +#. module: hr_expense_employee_payment +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form +msgid "Same as Employee Expense Journal" +msgstr "Igual que el Diario de Gastos del Empleado" diff --git a/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot b/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot index aa1f4395f..0a3880f0a 100644 --- a/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot +++ b/hr_expense_employee_payment/i18n/hr_expense_employee_payment.pot @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 18.0\n" +"Project-Id-Version: Odoo Server 19.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-25 10:30+0000\n" -"PO-Revision-Date: 2025-11-25 10:30+0000\n" +"POT-Creation-Date: 2026-06-10 09:58+0000\n" +"PO-Revision-Date: 2026-06-10 09:58+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -17,61 +17,86 @@ msgstr "" #. module: hr_expense_employee_payment #. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "%(expense_sheet_name)s | Expense conciliation" +#: code:addons/hr_expense_employee_payment/models/hr_expense.py:0 +msgid "%(expense_name)s | Expense conciliation" msgstr "" #. module: hr_expense_employee_payment -#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense_sheet -msgid "Expense Report" +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form +msgid "" +"Accounting journal for intermediate entries to allow pay real Bills to " +"employees. This journal must allow the creation of moves of type 'entry'." msgstr "" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "" -"Expense conciliation entry for '%(expense_sheet_name)s' expense sheet and " -"taking in consideration '%(vendor_move_names)s' moves." +#: model:ir.model,name:hr_expense_employee_payment.model_res_company +msgid "Companies" msgstr "" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "" -"Only expense sheets in 'To Submit' or 'Submitted' state can be splitted. " -"Expense Sheet '%s' is in state '%s'." +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__conciliation_move_id +msgid "Conciliation Entry" msgstr "" #. module: hr_expense_employee_payment -#: model:ir.actions.server,name:hr_expense_employee_payment.hr_expense_sheet_split_action -msgid "Split Expenses" +#: model:ir.model,name:hr_expense_employee_payment.model_res_config_settings +msgid "Config Settings" msgstr "" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 -msgid "Splitted Expense Sheets" +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__display_name +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__display_name +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_expense_employee_payment +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form +msgid "Employee Expense Intermediate Journal" +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.model,name:hr_expense_employee_payment.model_hr_expense +msgid "Expense" msgstr "" #. module: hr_expense_employee_payment #. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: code:addons/hr_expense_employee_payment/models/hr_expense.py:0 msgid "" -"This expense sheet has been created by splitting the expense sheet '%s'." +"Expense conciliation entry for '%(expense_name)s' expense and taking in " +"consideration '%(vendor_move_names)s' moves." msgstr "" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_hr_expense__id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_company__expense_intermediate_journal_id +#: model:ir.model.fields,field_description:hr_expense_employee_payment.field_res_config_settings__expense_intermediate_journal_id +msgid "Intermediate Expense Journal" +msgstr "" + +#. module: hr_expense_employee_payment +#: model:ir.model.fields,help:hr_expense_employee_payment.field_hr_expense__conciliation_move_id msgid "" -"You cannot register a payments for Employees and Vendors at the same time" +"Intermediate entry created to reconcile with the vendor bill and payment " +"later." msgstr "" #. module: hr_expense_employee_payment -#. odoo-python -#: code:addons/hr_expense_employee_payment/models/hr_expense_sheet.py:0 +#: model:ir.model.fields,help:hr_expense_employee_payment.field_res_company__expense_intermediate_journal_id +#: model:ir.model.fields,help:hr_expense_employee_payment.field_res_config_settings__expense_intermediate_journal_id msgid "" -"You have multiple expense journals defined, please set a single expense " -"journal in the company settings to proceed with the payment for Employees or" -" pay one by one." +"Journal used to record intermediate entries for employee expenses.\n" +"If not set, Expense Journal will be used instead." +msgstr "" + +#. module: hr_expense_employee_payment +#: model_terms:ir.ui.view,arch_db:hr_expense_employee_payment.res_config_settings_view_form +msgid "Same as Employee Expense Journal" msgstr "" diff --git a/hr_expense_employee_payment/models/__init__.py b/hr_expense_employee_payment/models/__init__.py index f2da9b513..60b22f4ee 100644 --- a/hr_expense_employee_payment/models/__init__.py +++ b/hr_expense_employee_payment/models/__init__.py @@ -1,4 +1,4 @@ from . import res_company from . import res_config_settings -from . import hr_expense_sheet from . import hr_expense +from . import account_move diff --git a/hr_expense_employee_payment/models/account_move.py b/hr_expense_employee_payment/models/account_move.py new file mode 100644 index 000000000..191a83f7c --- /dev/null +++ b/hr_expense_employee_payment/models/account_move.py @@ -0,0 +1,31 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import models +from odoo.fields import Domain + + +class AccountMove(models.Model): + _inherit = "account.move" + + def action_force_register_payment(self): + # Check if there are any Expense Vendor Moves in order to modify normal workflow + expense_vendor_moves_domain = Domain.AND( + [ + Domain("expense_ids", "!=", False), + self.env["hr.expense"]._get_vendor_moves_domain(), + ] + ) + expense_moves = self.filtered_domain(expense_vendor_moves_domain) + if not expense_moves: + return super().action_force_register_payment() + + # Other Payable lines + other_moves = self - expense_moves + payable_lines = other_moves.line_ids + # Payable Employee Expense lines + expense_moves.expense_ids._create_conciliation_moves(auto_reconcile=True) + payable_lines |= expense_moves.expense_ids._get_conciliation_payable_move_lines( + for_employee=True + ) + return payable_lines.action_register_payment() diff --git a/hr_expense_employee_payment/models/hr_expense.py b/hr_expense_employee_payment/models/hr_expense.py index 9e5230741..7b9899676 100644 --- a/hr_expense_employee_payment/models/hr_expense.py +++ b/hr_expense_employee_payment/models/hr_expense.py @@ -1,7 +1,8 @@ # Copyright 2025 Moduon Team S.L. # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) -from odoo import api, fields, models +from odoo import Command, api, fields, models +from odoo.tools import float_is_zero class HrExpense(models.Model): @@ -10,18 +11,192 @@ class HrExpense(models.Model): conciliation_move_id = fields.Many2one( comodel_name="account.move", string="Conciliation Entry", - compute="_compute_conciliation_move_id", - store=True, help="Intermediate entry created to reconcile with the vendor " "bill and payment later.", ) - @api.depends("sheet_id.account_move_ids.move_type") - def _compute_conciliation_move_id(self): + @api.model + def _get_vendor_moves_domain(self): + """Return the domain to get the vendor moves linked to the expense.""" + return fields.Domain("move_type", "=", "in_invoice") + + def _get_vendor_move(self): + """Get the vendor move linked to the expense.""" + vendor_moves = self.env["account.move"].browse() + vendor_moves_domain = self._get_vendor_moves_domain() + for expense in self: + if expense.account_move_id.filtered_domain(vendor_moves_domain): + vendor_moves |= expense.account_move_id + return vendor_moves + + def _get_vendor_payable_move_lines(self): + """Get the payable move lines from the vendor bills linked to + selected expense sheets that has *something to be paid*.""" + return self._get_vendor_move().line_ids.filtered( + lambda line: line.display_type == "payment_term" + and not float_is_zero( + line.amount_residual, precision_rounding=line.currency_id.rounding + ) + ) + + def _prepare_conciliation_move_vals(self): + """Prepare the values for the conciliation entry move to + reconcile with the vendor bill and payment later.""" + result = [] + for expense in self: + employee_p = expense.employee_id.sudo().work_contact_id + company = expense.company_id + exp_intermediate_journal = ( + company.expense_intermediate_journal_id + or company.expense_journal_id + or expense.journal_id + ) + vendor_payable_move_lines = expense._get_vendor_payable_move_lines() + vendor_date = vendor_payable_move_lines.move_id[:1].date + result.append( + { + "company_id": company.id, + "journal_id": exp_intermediate_journal.id, + "ref": self.env._( + "%(expense_name)s | Expense conciliation", + expense_name=expense.name, + ), + "narration": self.env._( + "Expense conciliation entry for " + "'%(expense_name)s' expense " + "and taking in consideration '%(vendor_move_names)s' moves.", + expense_name=expense.name, + vendor_move_names=", ".join( + vendor_payable_move_lines.mapped("move_id.name") + ), + ), + "move_type": "entry", + "date": vendor_date, + "currency_id": expense.currency_id.id, + "line_ids": [ + Command.create( + { + "name": expense.name, + "partner_id": employee_p.id, + "account_id": employee_p.property_account_payable_id.id, + "debit": 0.0, + "credit": expense.amount_residual, + } + ), + *[ + Command.create( + { + "name": f"{payable_move_line.move_id.name} " + f"({payable_move_line.move_id.ref})", + "partner_id": payable_move_line.partner_id.id, + "account_id": payable_move_line.account_id.id, + "debit": -payable_move_line.amount_residual, + "credit": 0.0, + } + ) + for payable_move_line in vendor_payable_move_lines + ], + ], + } + ) + + return result + + def _get_conciliation_payable_move_lines(self, for_employee=False): + """Obtain the posted payable move lines from the conciliation entries + for selected expense sheets. + + :param for_employee: True: Lines for the employee. False: Lines for the vendor. + :return: A recordset of account.move.line matching conciliation payable lines. + """ + conciliation_payable_lines = self.env["account.move.line"].browse() + for expense in self.filtered("conciliation_move_id"): + if for_employee: + partner = expense.employee_id.sudo().work_contact_id # Employee + else: + partner = expense._get_vendor_move().partner_id # Vendor + amount_field = "credit" if for_employee else "debit" + conciliation_payable_lines |= ( + expense.conciliation_move_id.line_ids.filtered_domain( + [ + ("partner_id", "=", partner.id), + (amount_field, ">", 0.0), + ] + ) + ) + return conciliation_payable_lines + + def _reconcile_conciliation_move_with_vendor_bills(self): + """Reconcile the Conciliation Entry and the Vendor Bill.""" + for expense in self.filtered("conciliation_move_id"): + if expense.conciliation_move_id.state == "draft": + expense.conciliation_move_id.sudo().action_post() + # Select the Vendor lines to reconcile + conciliation_vendor_payable_move_lines = ( + expense._get_conciliation_payable_move_lines(for_employee=False) + ) + vendor_payable_move_lines = expense._get_vendor_payable_move_lines() + # Check Vendor Bills that needs to be reconciled + if conciliation_vendor_payable_move_lines and vendor_payable_move_lines: + lines_to_reconcile = ( + conciliation_vendor_payable_move_lines | vendor_payable_move_lines + ) + lines_to_reconcile.reconcile() + + def _create_conciliation_moves(self, auto_reconcile=True): + """Create an intermediate entry to reconcile with the vendor bills + and payment later. + + :param auto_reconcile: Automatically reconcile the conciliation moves + with the vendor bills. + :return: A recordset of account.move with the created conciliation moves. + """ + conciliation_moves = self.env["account.move"].browse() for expense in self: - if not expense.sheet_id.account_move_ids: + if not expense.conciliation_move_id: + expense.conciliation_move_id = ( + self.env["account.move"] + .sudo() + .create(expense._prepare_conciliation_move_vals()) + ) + conciliation_moves |= expense.conciliation_move_id + if auto_reconcile: + self._reconcile_conciliation_move_with_vendor_bills() + return conciliation_moves + + def action_open_conciliation_move(self): + """Open the conciliation entry created for the expense.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "account.move", + "name": self.conciliation_move_id.name, + "view_mode": "form", + "res_id": self.conciliation_move_id.id, + "views": [(False, "form")], + } + + @api.depends("conciliation_move_id.line_ids.reconciled") + def _compute_state(self): + res = super()._compute_state() + for expense in self: + if not expense.conciliation_move_id or not expense.account_move_id: continue - expense_conciliation_moves = expense.sheet_id._get_conciliation_moves( - posted=False + conciliation_employee_payable_move_lines = ( + expense._get_conciliation_payable_move_lines( + for_employee=True, + ) ) - expense.conciliation_move_id = expense_conciliation_moves[:1] + if all(conciliation_employee_payable_move_lines.mapped("reconciled")): + expense.state = "paid" + else: + # Conciliation entry exists but is not fully paid + expense.state = "in_payment" + return res + + def action_pay_employee(self): + """Register employee payment""" + conciliation_employee_payable_move_lines = ( + self._get_conciliation_payable_move_lines(for_employee=True) + ) + return conciliation_employee_payable_move_lines.action_register_payment() diff --git a/hr_expense_employee_payment/models/hr_expense_sheet.py b/hr_expense_employee_payment/models/hr_expense_sheet.py deleted file mode 100644 index 6b08ebcc0..000000000 --- a/hr_expense_employee_payment/models/hr_expense_sheet.py +++ /dev/null @@ -1,332 +0,0 @@ -# Copyright 2025 Moduon Team S.L. -# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) - -from odoo import Command, api, exceptions, models -from odoo.tools import float_compare, float_is_zero - - -class HrExpenseSheet(models.Model): - _inherit = "hr.expense.sheet" - - def _get_vendor_moves(self, for_employee=False): - """Get the vendor bills linked to selected expense sheets. - - :param for_employee: True: Bills for the employee. False: Bills for the vendor. - :return: A recordset of account.move matching vendor or employee bills. - """ - entry_moves = self.account_move_ids.filtered_domain( - [("move_type", "!=", "entry")] - ) - vendor_moves = entry_moves.filtered( - lambda move: move.partner_id - != move.expense_sheet_id.employee_id.sudo().work_contact_id - ) - if for_employee: - return entry_moves - vendor_moves - return vendor_moves - - def _get_vendor_payable_move_lines(self): - """Get the payable move lines from the vendor - bills linked to selected expense sheets.""" - vendor_bills = self._get_vendor_moves(for_employee=False) - return vendor_bills.mapped("line_ids").filtered( - lambda line: line.display_type == "payment_term" - and not float_is_zero( - line.amount_residual, precision_rounding=line.currency_id.rounding - ) - ) - - def _get_conciliation_moves(self, posted=False): - """Obtain the conciliation entries for selected expense sheets, if any. - - :param posted: If True, only posted moves are returned. - :return: A recordset of account.move matching conciliation entries. - """ - conciliation_moves = self.env["account.move"].browse() - valid_states = ["posted"] if posted else ["draft", "cancel", "posted"] - for sheet in self: - conciliation_moves |= sheet.account_move_ids.filtered_domain( - [ - ("move_type", "=", "entry"), - ("state", "in", valid_states), - ] - ) - return conciliation_moves - - def _get_conciliation_payable_move_lines(self, for_employee=False): - """Obtain the posted payable move lines from the conciliation entries - for selected expense sheets. - - :param for_employee: True: Lines for the employee. False: Lines for the vendor. - :return: A recordset of account.move.line matching conciliation payable lines. - """ - conciliation_payable_lines = self.env["account.move.line"].browse() - for conciliation_move in self._get_conciliation_moves(posted=True): - employee_partner = ( - conciliation_move.expense_sheet_id.employee_id.sudo().work_contact_id - ) - conciliation_payable_employee_lines = ( - conciliation_move.line_ids.filtered_domain( - [ - ("partner_id", "=", employee_partner.id), - ] - ) - ) - if for_employee: - conciliation_payable_lines |= conciliation_payable_employee_lines - else: - conciliation_payable_lines |= ( - conciliation_move.line_ids - conciliation_payable_employee_lines - ) - return conciliation_payable_lines - - def _prepare_conciliation_move_vals(self): - """Prepare the values for the conciliation entry move to - reconcile with the vendor bill and payment later.""" - result = [] - for sheet in self: - employee_p = sheet.employee_id.sudo().work_contact_id - company = sheet.company_id - exp_intermediate_journal = ( - company.expense_intermediate_journal_id - or company.expense_journal_id - or sheet.journal_id - ) - vendor_payable_move_lines = sheet._get_vendor_payable_move_lines() - result.append( - { - "company_id": company.id, - "journal_id": exp_intermediate_journal.id, - "ref": self.env._( - "%(expense_sheet_name)s | Expense conciliation", - expense_sheet_name=self.name, - ), - "narration": self.env._( - "Expense conciliation entry for " - "'%(expense_sheet_name)s' expense sheet " - "and taking in consideration '%(vendor_move_names)s' moves.", - expense_sheet_name=sheet.name, - vendor_move_names=", ".join( - vendor_payable_move_lines.mapped("move_id.name") - ), - ), - "move_type": "entry", - "date": sheet.accounting_date, - "currency_id": sheet.currency_id.id, - "expense_sheet_id": sheet.id, - "line_ids": [ - Command.create( - { - "name": sheet.name, - "partner_id": employee_p.id, - "account_id": employee_p.property_account_payable_id.id, - "debit": 0.0, - "credit": sheet.amount_residual, - } - ), - *[ - Command.create( - { - "name": f"{payable_move_line.move_id.name} " - f"({payable_move_line.move_id.ref})", - "partner_id": payable_move_line.partner_id.id, - "account_id": payable_move_line.account_id.id, - "debit": -payable_move_line.amount_residual, - "credit": 0.0, - } - ) - for payable_move_line in vendor_payable_move_lines - ], - ], - } - ) - - return result - - def _reconcile_conciliation_move_with_vendor_bills(self): - """Reconcile the Conciliation Entry and the Vendor Bill.""" - for sheet in self: - # Get the conciliation entry and post it if needed - conciliation_move = sheet._get_conciliation_moves(posted=False) - if not conciliation_move: - continue - if conciliation_move.state == "draft": - conciliation_move.sudo().action_post() - # Select the payable vendor lines to reconcile - conciliation_vendor_payable_move_lines = ( - sheet._get_conciliation_payable_move_lines(for_employee=False) - ) - vendor_payable_move_lines = sheet._get_vendor_payable_move_lines() - lines_to_reconcile = ( - conciliation_vendor_payable_move_lines | vendor_payable_move_lines - ) - lines_to_reconcile.reconcile() - - def _create_conciliation_moves(self): - """Create an intermediate entry to reconcile with - the vendor bills and payment later.""" - # Get the payable vendor bill lines to reconcile - conciliation_moves = self._get_conciliation_moves(posted=True) - for remaining_sheet in self - conciliation_moves.mapped("expense_sheet_id"): - remaining_conciliation_move = ( - self.env["account.move"] - .sudo() - .create(remaining_sheet._prepare_conciliation_move_vals()) - ) - remaining_sheet._reconcile_conciliation_move_with_vendor_bills() - conciliation_moves |= remaining_conciliation_move - return conciliation_moves - - def action_register_payment(self): - """Override to create the payment for the Conciliation entry to the Employee and - create the conciliation entry if needed.""" - # Check if there are Vendor and Employee Bills at the same time - employee_bills = self._get_vendor_moves(for_employee=True) - vendor_bills = self._get_vendor_moves(for_employee=False) - if vendor_bills and employee_bills: - raise exceptions.UserError( - self.env._( - "You cannot register a payments for Employees and Vendors " - "at the same time" - ) - ) - # If payments are only for employee bills, use the normal flow - if employee_bills: - return super().action_register_payment() - # At this point, we can assume that Vendor bills are not for the employee - # Ensure all conciliation entries are created, posted and conciled - conciliation_moves = self._get_conciliation_moves(posted=False) - conciliation_moves.filtered(lambda move: move.state == "draft").mapped( - "expense_sheet_id" - )._reconcile_conciliation_move_with_vendor_bills() - # Remaining sheets to process that are not in the conciliation moves - sheets_to_create_conciliation_moves = self - conciliation_moves.mapped( - "expense_sheet_id" - ) - if sheets_to_create_conciliation_moves: - conciliation_moves |= ( - sheets_to_create_conciliation_moves._create_conciliation_moves() - ) - # All conciliation entries are created, so continue with the payment process - # Choose company Expense Journal first, if not set, use the sheet Journal - expense_journal = self.company_id.expense_journal_id or self.journal_id - if len(expense_journal) > 1: - raise exceptions.UserError( - self.env._( - "You have multiple expense journals defined, please set a single " - "expense journal in the company settings to proceed with the " - "payment for Employees or pay one by one." - ) - ) - # Check if all payments belongs to the same employee to set default bank account - conciliation_emp_pay_move_lines = self._get_conciliation_payable_move_lines( - for_employee=True - ) - default_partner_bank_id = None - if len(conciliation_emp_pay_move_lines.mapped("partner_id")) == 1: - employee_partner = conciliation_emp_pay_move_lines.mapped("partner_id")[0] - default_partner_bank_id = employee_partner.bank_ids[:1].id - # Return the action to register payment for the conciliation entries - # Group payment if all conciliation entries belongs to the same employee - return conciliation_emp_pay_move_lines.action_register_payment( - ctx={ - "default_partner_bank_id": default_partner_bank_id, - "default_journal_id": expense_journal.id, - "default_group_payment": bool(default_partner_bank_id), - } - ) - - @api.depends("account_move_ids.line_ids.reconciled") - def _compute_from_account_move_ids(self): - res = super()._compute_from_account_move_ids() - for sheet in self: - # Only manage paid by Employee mode - if sheet.payment_mode != "own_account": - continue - # If all bills belongs to the employee, we don't have nothing to do - employee_bills = sheet._get_vendor_moves(for_employee=True) - if employee_bills: - continue - # At this point, we can assume that Vendor bills are not for the employee - # Check the payment status for the employee on the conciliation entry - employee_conciliation_move_lines = ( - sheet._get_conciliation_payable_move_lines(for_employee=True) - ) - if not employee_conciliation_move_lines: - # No conciliation entry created yet or is not valid - sheet.payment_state = "not_paid" - sheet.amount_residual = sheet.total_amount - continue - - if all(employee_conciliation_move_lines.mapped("reconciled")): - sheet.payment_state = "paid" - sheet.amount_residual = 0.0 - else: - not_reconciled_lines = employee_conciliation_move_lines.filtered( - lambda line: not line.reconciled - ) - sheet.amount_residual = -sum( - not_reconciled_lines.mapped("amount_residual") - ) - if ( - float_compare( - sheet.amount_residual, - -sum(not_reconciled_lines.mapped("balance")), - precision_rounding=sheet.currency_id.rounding, - ) - == 0 - ): - sheet.payment_state = "not_paid" - else: - sheet.payment_state = "partial" - return res - - def _action_split_expenses(self): - """Action to split expense sheet into multiple sheets - with one expense line each.""" - result_sheets = self.env["hr.expense.sheet"].browse() - for sheet in self: - if sheet.state not in ("draft", "submit"): - raise exceptions.UserError( - self.env._( - "Only expense sheets in 'To Submit' or 'Submitted' state " - "can be splitted. Expense Sheet '%s' is in state '%s'.", - sheet.name, - sheet.state, - ) - ) - if len(sheet.expense_line_ids) <= 1: - result_sheets |= sheet - continue - for line in sheet.expense_line_ids: - new_sheet = sheet.copy(default={"name": f"{sheet.name} - {line.name}"}) - new_sheet.message_post( - body=self.env._( - "This expense sheet has been created by splitting " - "the expense sheet '%s'.", - sheet.name, - ) - ) - line.sheet_id = new_sheet - if sheet.state != "draft": # Submitted - new_sheet._do_submit() - result_sheets |= new_sheet - try: - sheet.sudo().unlink() - except exceptions.UserError: - # In case the user cannot unlink the sheet, refuse with a reason - sheet._do_refuse( - self.env._( - "This expense sheet has been splitted into multiple sheets." - ) - ) - action = self.env["ir.actions.actions"]._for_xml_id( - "hr_expense.action_hr_expense_sheet_all" - ) - action.update( - { - "domain": [("id", "in", result_sheets.ids)], - "name": self.env._("Splitted Expense Sheets"), - } - ) - return action diff --git a/hr_expense_employee_payment/readme/CONTEXT.md b/hr_expense_employee_payment/readme/CONTEXT.md index c773323eb..6cde4ef0b 100644 --- a/hr_expense_employee_payment/readme/CONTEXT.md +++ b/hr_expense_employee_payment/readme/CONTEXT.md @@ -1,7 +1,3 @@ This module has been created to allow payment to the employee when supplier invoices are recorded in expenses. It will be useful for you if the legislation in your country doesn't allow to create vendor bills to the employees. - -Each expense sheet is required to have only one expense because -Odoo does not allow creating more than one invoice for an expense sheet. -The *Split Expenses* action is provided to facilitate this operation. diff --git a/hr_expense_employee_payment/readme/CREDITS.md b/hr_expense_employee_payment/readme/CREDITS.md deleted file mode 100644 index f39961fbd..000000000 --- a/hr_expense_employee_payment/readme/CREDITS.md +++ /dev/null @@ -1,3 +0,0 @@ -The development of this module has been financially supported by: - -- Cámara de Comercio Alemana para España diff --git a/hr_expense_employee_payment/readme/DESCRIPTION.md b/hr_expense_employee_payment/readme/DESCRIPTION.md index ea1f26353..8fca38030 100644 --- a/hr_expense_employee_payment/readme/DESCRIPTION.md +++ b/hr_expense_employee_payment/readme/DESCRIPTION.md @@ -1,5 +1,5 @@ -This module extends the expense functionality so that expenses sheets paid by employees, -which are actual Vendor Bills, can be paid to the employee. +This module extends the expense functionality so that expenses paid by employees, +which are actual **Vendor Bills**, can be paid to the employee. To do this, an intermediate journal entry is created that transfers the debt from the supplier to the employee and also modifies the payment wizard so that diff --git a/hr_expense_employee_payment/readme/USAGE.md b/hr_expense_employee_payment/readme/USAGE.md index 924a7c830..34bbf0a00 100644 --- a/hr_expense_employee_payment/readme/USAGE.md +++ b/hr_expense_employee_payment/readme/USAGE.md @@ -1,12 +1,14 @@ To use this module, you need to: -1. Go to Expenses and create a new one that is paid by Employee -1. Create report and Submit to Manager -1. Change Journal to Vendor Bills -1. Approve this Expense Sheet -1. Go to the Journal Entry clicking on the Smart Button +1. Go to Expenses > Configuration > Settings and set *Employee Expense Journal* and *Employee Expense Intermediate Journal* +1. Go to Expenses and create a new one that is paid by Employee (to reimburse) +1. Submit to Manager +1. Click on Post Journal Entries +1. Select a Journal to register the real Vendor Bill on the Wizard +1. Click on Reset to Draft 1. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, journal, etc) -1. Confirm the invoice, or go to the Expense Sheet and Post Journal Entries +1. Confirm the Bill 1. Click on Pay and fill the payment for the Employee. Partial payments are allowed -1. On the Journal Entries Smart Button, you could see a new one intermediate entry +1. If you make a partial payment or cancel the payment over the Bill, you can continue the Payment on the Expense +1. On the Expense, you could see a new one intermediate entry on the Smart Button 1. Finish the payment process if you partially pay by clicking again on Pay diff --git a/hr_expense_employee_payment/static/description/index.html b/hr_expense_employee_payment/static/description/index.html index ca34dce84..0bcecea39 100644 --- a/hr_expense_employee_payment/static/description/index.html +++ b/hr_expense_employee_payment/static/description/index.html @@ -3,7 +3,7 @@ -Hr Expense - Employee Payment +README.rst -
-

Hr Expense - Employee Payment

+
+ + +Odoo Community Association + +
+

Hr Expense - Employee Payment

-

Alpha License: LGPL-3 OCA/hr-expense Translate me on Weblate Try me on Runboat

-

This module extends the expense functionality so that expenses sheets -paid by employees, which are actual Vendor Bills, can be paid to the +

Alpha License: LGPL-3 OCA/hr-expense Translate me on Weblate Try me on Runboat

+

This module extends the expense functionality so that expenses paid by +employees, which are actual Vendor Bills, can be paid to the employee.

To do this, an intermediate journal entry is created that transfers the debt from the supplier to the employee and also modifies the payment @@ -393,24 +398,20 @@

Hr Expense - Employee Payment

  • Credits
  • -

    Use Cases / Context

    +

    Use Cases / Context

    This module has been created to allow payment to the employee when supplier invoices are recorded in expenses.

    It will be useful for you if the legislation in your country doesn’t allow to create vendor bills to the employees.

    -

    Each expense sheet is required to have only one expense because Odoo -does not allow creating more than one invoice for an expense sheet. The -Split Expenses action is provided to facilitate this operation.

    -

    Configuration

    +

    Configuration

    To configure this module, you need to:

    1. Go to Expenses > Configuration > Settings
    2. @@ -423,58 +424,55 @@

      Configuration

    -

    Usage

    +

    Usage

    To use this module, you need to:

      -
    1. Go to Expenses and create a new one that is paid by Employee
    2. -
    3. Create report and Submit to Manager
    4. -
    5. Change Journal to Vendor Bills
    6. -
    7. Approve this Expense Sheet
    8. -
    9. Go to the Journal Entry clicking on the Smart Button
    10. +
    11. Go to Expenses > Configuration > Settings and set Employee Expense +Journal and Employee Expense Intermediate Journal
    12. +
    13. Go to Expenses and create a new one that is paid by Employee (to +reimburse)
    14. +
    15. Submit to Manager
    16. +
    17. Click on Post Journal Entries
    18. +
    19. Select a Journal to register the real Vendor Bill on the Wizard
    20. +
    21. Click on Reset to Draft
    22. Modify the Vendor Bill like a normal Vendor Bill (partner, bill ref, journal, etc)
    23. -
    24. Confirm the invoice, or go to the Expense Sheet and Post Journal -Entries
    25. +
    26. Confirm the Bill
    27. Click on Pay and fill the payment for the Employee. Partial payments are allowed
    28. -
    29. On the Journal Entries Smart Button, you could see a new one -intermediate entry
    30. +
    31. If you make a partial payment or cancel the payment over the Bill, +you can continue the Payment on the Expense
    32. +
    33. On the Expense, you could see a new one intermediate entry on the +Smart Button
    34. Finish the payment process if you partially pay by clicking again on Pay
    -

    Bug Tracker

    +

    Bug Tracker

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

    +feedback.

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Moduon
    -

    Contributors

    +

    Contributors

    -
    -

    Other credits

    -

    The development of this module has been financially supported by:

    -
      -
    • Cámara de Comercio Alemana para España
    • -
    -
    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -484,10 +482,11 @@

    Maintainers

    promote its widespread use.

    Current maintainers:

    Shide rafaelbn

    -

    This module is part of the OCA/hr-expense project on GitHub.

    +

    This module is part of the OCA/hr-expense project on GitHub.

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

    +
    diff --git a/hr_expense_employee_payment/tests/__init__.py b/hr_expense_employee_payment/tests/__init__.py index 3f54611e2..29f89d8f3 100644 --- a/hr_expense_employee_payment/tests/__init__.py +++ b/hr_expense_employee_payment/tests/__init__.py @@ -1 +1 @@ -from . import test_hr_expense_sheet +from . import test_hr_expense diff --git a/hr_expense_employee_payment/tests/test_hr_expense.py b/hr_expense_employee_payment/tests/test_hr_expense.py new file mode 100644 index 000000000..6afe485e5 --- /dev/null +++ b/hr_expense_employee_payment/tests/test_hr_expense.py @@ -0,0 +1,145 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + + +from odoo.tests import tagged + +from odoo.addons.hr_expense.tests.common import TestExpenseCommon + + +@tagged("-at_install", "post_install") +class TestHrExpenseEmployeePayment(TestExpenseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_one_expense_two_payments(self): + """Test paying one expense with two payment""" + # Create and submit expense + expense = self.create_expenses([{"payment_mode": "own_account"}]) + self.assertEqual(expense.state, "draft") + expense.action_submit() + self.assertEqual(expense.state, "submitted") + expense.action_approve() + self.assertEqual(expense.state, "approved") + # Create Expense Vendor Bill + self.post_expenses_with_wizard(expense) + self.assertEqual(expense.account_move_id.state, "posted") + # Change Vendor Bill to in_invoice + expense.account_move_id.button_draft() + expense.account_move_id.move_type = "in_invoice" + expense.account_move_id.partner_id = self.partner_a.id + expense.account_move_id.action_post() + self.assertEqual(expense.account_move_id.state, "posted") + self.assertEqual(expense.state, "posted") + # Generate Conciliation Entry, but don't pay yet + expense.account_move_id.action_register_payment() + self.assertTrue(expense.conciliation_move_id) + self.assertEqual(expense.state, "in_payment") + # Check vendor Bill has been reconciled with the conciliation entry + vendor_payable_move_line = expense._get_conciliation_payable_move_lines( + for_employee=False + ) + self.assertTrue(vendor_payable_move_line.reconciled) + # Pay from Expense: Payment 1 + payment_wizard_action = expense.action_pay_employee() + payment_wizard = ( + self.env[payment_wizard_action["res_model"]] + .with_context(**payment_wizard_action["context"]) + .create( + { + "amount": 600.0, + "journal_id": self.company_data["default_journal_bank"].id, + } + ) + ) + self.assertEqual( + payment_wizard.partner_bank_id, + self.expense_employee.sudo().bank_account_ids, + ) + payment_wizard.action_create_payments() + self.assertEqual(expense.state, "in_payment") + # Pay from Expense: Payment 2 + payment_wizard_action = expense.action_pay_employee() + payment_wizard = ( + self.env[payment_wizard_action["res_model"]] + .with_context(**payment_wizard_action["context"]) + .create( + { + "amount": 400.0, + "journal_id": self.company_data["default_journal_bank"].id, + } + ) + ) + self.assertEqual( + payment_wizard.partner_bank_id, + self.expense_employee.sudo().bank_account_ids, + ) + payment_wizard.action_create_payments() + self.assertEqual(expense.state, "paid") + + def test_pay_expense_and_vendor_bill(self): + """Test paying one expense with two payment""" + # Create and submit expense + expense = self.create_expenses([{"payment_mode": "own_account"}]) + expense.action_submit() + expense.action_approve() + # Create Expense Vendor Bill + self.post_expenses_with_wizard(expense) + # Change Vendor Bill to in_invoice + expense.account_move_id.button_draft() + expense.account_move_id.move_type = "in_invoice" + expense.account_move_id.partner_id = self.partner_a.id + expense.account_move_id.action_post() + self.assertEqual(expense.account_move_id.state, "posted") + self.assertEqual(expense.state, "posted") + # Create normal Bill (no Expense) + normal_bill = self.init_invoice( + "in_invoice", + partner=self.partner_a, + products=[self.product_a], + # amounts=[600.0], + post=True, + ) + # Pay both Bills at once + payment_wizard_action = ( + expense.account_move_id | normal_bill + ).action_force_register_payment() + payment_wizard = ( + self.env[payment_wizard_action["res_model"]] + .with_context(**payment_wizard_action["context"]) + .create( + { + "amount": normal_bill.amount_total + expense.total_amount, + "journal_id": self.company_data["default_journal_bank"].id, + } + ) + ) + payment_wizard.action_create_payments() + # Check Expense + self.assertEqual(expense.state, "paid") + self.assertTrue(expense.conciliation_move_id) + expense_vendor_payable_move_line = expense._get_conciliation_payable_move_lines( + for_employee=False + ) + self.assertTrue(expense_vendor_payable_move_line.reconciled) + expense_employee_payable_move_line = ( + expense._get_conciliation_payable_move_lines(for_employee=True) + ) + self.assertTrue(expense_employee_payable_move_line.reconciled) + # Check normal Bill + normal_bill_payable_move_line = normal_bill.line_ids.filtered_domain( + [ + ("display_type", "=", "payment_term"), + ] + ) + self.assertTrue(normal_bill_payable_move_line.reconciled) + # Check Bills and Expenses are not conciliated between each other + self.assertEqual( + len( + expense_employee_payable_move_line.full_reconcile_id + | expense_vendor_payable_move_line.full_reconcile_id + | normal_bill_payable_move_line.full_reconcile_id + ), + 3, + ) diff --git a/hr_expense_employee_payment/tests/test_hr_expense_sheet.py b/hr_expense_employee_payment/tests/test_hr_expense_sheet.py deleted file mode 100644 index 619f2aaed..000000000 --- a/hr_expense_employee_payment/tests/test_hr_expense_sheet.py +++ /dev/null @@ -1,316 +0,0 @@ -# Copyright 2025 Moduon Team S.L. -# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) - - -from odoo import Command, exceptions -from odoo.tests import Form, tagged - -from odoo.addons.hr_expense.tests.common import TestExpenseCommon - - -@tagged("-at_install", "post_install") -class TestHrExpenseEmployeePayment(TestExpenseCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.exp_emp_partner = cls.expense_employee.work_contact_id - cls.exp_emp_partner_bank_account = cls.env["res.partner.bank"].create( - { - "acc_number": "1112223334", - "partner_id": cls.exp_emp_partner.id, - "acc_type": "bank", - } - ) - - def _force_approve_with_draft_move(self, expense_sheet): - """Force the approval of an expense and left - the related move in draft state. - This function allows to be compatible with other hr-expense modules.""" - if not expense_sheet.account_move_ids: - expense_sheet.action_sheet_move_post() - move = expense_sheet.account_move_ids - if move.state == "posted": - move.button_draft() - - def test_two_payments_one_expense_sheet(self): - """Test paying one expense sheet with two payments""" - # Create sheet and vendor bill - expense_sheet = self.create_expense_report( - { - "name": "SHEET 2p 1e", - "journal_id": self.company_data["default_journal_purchase"].id, - "expense_line_ids": [ - Command.create( - { - "name": "EXPENSE 2p 1e", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 100.00, - } - ) - ], - } - ) - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet) - # Select current Employee draft Bill and change to be a Vendor Bill - vendor_bill = expense_sheet._get_vendor_moves(for_employee=True) - vendor_bill.write({"partner_id": self.partner_a.id, "ref": "VB_01"}) - vendor_bill = expense_sheet._get_vendor_moves(for_employee=False) - expense_sheet.action_sheet_move_post() - self.assertTrue(vendor_bill) - # Generate Payment Wizard - action_data = expense_sheet.action_register_payment() - # Check conciliation move exists - conciliation_move = expense_sheet._get_conciliation_moves(posted=True) - self.assertTrue(conciliation_move) - # Check that the vendor payable move line is reconciled - vendor_payable_move_line = expense_sheet._get_conciliation_payable_move_lines( - for_employee=False - ) - self.assertEqual(vendor_payable_move_line.reconciled, True) - # Check that the employee payable move line is not reconciled - employee_payable_move_line = expense_sheet._get_conciliation_payable_move_lines( - for_employee=True - ) - self.assertEqual(employee_payable_move_line.reconciled, False) - # Check payment state on sheet is not paid - self.assertEqual(expense_sheet.payment_state, "not_paid") - # Pay 1 - with Form( - self.env["account.payment.register"].with_context(**action_data["context"]) - ) as pay_form: - self.assertEqual( - pay_form.partner_bank_id, self.exp_emp_partner_bank_account - ) - pay_form.amount = 60.00 - payment = pay_form.save() - payment.action_create_payments() - self.assertEqual(expense_sheet.payment_state, "partial") - self.assertEqual(expense_sheet.amount_residual, 40.00) - # Pay 2 - action_data = expense_sheet.action_register_payment() - with Form( - self.env["account.payment.register"].with_context(**action_data["context"]) - ) as pay_form: - self.assertEqual( - pay_form.partner_bank_id, self.exp_emp_partner_bank_account - ) - self.assertEqual(pay_form.amount, 40.00) - payment = pay_form.save() - payment.action_create_payments() - self.assertEqual(expense_sheet.payment_state, "paid") - self.assertEqual(expense_sheet.amount_residual, 0.00) - # Check that the employee payable move line is now reconciled - self.assertEqual(employee_payable_move_line.reconciled, True) - # Check that the vendor payable move line is still reconciled - self.assertEqual(vendor_payable_move_line.reconciled, True) - - def test_one_payment_two_expense_sheets(self): - """Test paying two expense sheets with one payment""" - # Create sheet 1 and vendor bill - expense_sheet1 = self.create_expense_report( - { - "name": "SHEET1 1p 2e", - "journal_id": self.company_data["default_journal_purchase"].id, - "expense_line_ids": [ - Command.create( - { - "name": "EXPENSE1 1p 2e", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 100.00, - } - ) - ], - } - ) - expense_sheet1.action_submit_sheet() - expense_sheet1.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet1) - expense_sheet1._get_vendor_moves(for_employee=True).write( - {"partner_id": self.partner_a.id, "ref": "VB_01"} - ) - expense_sheet1.action_sheet_move_post() - # Create sheet 2 and vendor bill - expense_sheet2 = self.create_expense_report( - { - "name": "SHEET2 1p 2e", - "journal_id": self.company_data["default_journal_purchase"].id, - "expense_line_ids": [ - Command.create( - { - "name": "EXPENSE2 1p 2e", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 200.00, - } - ) - ], - } - ) - expense_sheet2.action_submit_sheet() - expense_sheet2.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet2) - expense_sheet2._get_vendor_moves(for_employee=True).write( - {"partner_id": self.partner_a.id, "ref": "VB_02"} - ) - expense_sheet2.action_sheet_move_post() - # Pay both sheets at once - expense_sheets = expense_sheet1 | expense_sheet2 - action_data = expense_sheets.action_register_payment() - with Form( - self.env["account.payment.register"].with_context(**action_data["context"]) - ) as pay_form: - self.assertEqual( - pay_form.partner_bank_id, self.exp_emp_partner_bank_account - ) - self.assertTrue(pay_form.group_payment) - payment = pay_form.save() - payment.action_create_payments() - self.assertEqual(expense_sheet1.payment_state, "paid") - self.assertEqual(expense_sheet2.payment_state, "paid") - # Check that the employee payable move lines are now reconciled - employee_payable_move_lines = ( - expense_sheets._get_conciliation_payable_move_lines(for_employee=True) - ) - self.assertTrue(employee_payable_move_lines.mapped("reconciled")) - # Check that the vendor payable move line is still reconciled - vendor_payable_move_lines = expense_sheets._get_conciliation_payable_move_lines( - for_employee=False - ) - self.assertTrue(vendor_payable_move_lines.mapped("reconciled")) - - def test_unable_to_pay_employee_and_vendor_bill_at_the_same_time(self): - """Test trying to pay a Vendor Expense Sheet and an - Employee Expense Sheet at the same time""" - # Create sheet 1 and vendor bill - expense_sheet1 = self.create_expense_report( - { - "name": "SHEET1 1p 2e error", - "journal_id": self.company_data["default_journal_purchase"].id, - "expense_line_ids": [ - Command.create( - { - "name": "EXPENSE1 1p 2e error", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 100.00, - } - ) - ], - } - ) - expense_sheet1.action_submit_sheet() - expense_sheet1.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet1) - expense_sheet1._get_vendor_moves(for_employee=True).write( - {"partner_id": self.partner_a.id, "ref": "VB_01"} - ) - expense_sheet1.action_sheet_move_post() - # Create sheet 2 and employee bill - expense_sheet2 = self.create_expense_report( - { - "name": "SHEET2 1p 2e error", - "journal_id": self.company_data["default_journal_purchase"].id, - "expense_line_ids": [ - Command.create( - { - "name": "EXPENSE2 1p 2e error", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 200.00, - } - ) - ], - } - ) - expense_sheet2.action_submit_sheet() - expense_sheet2.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet2) - expense_sheet2.action_sheet_move_post() - # Pay both sheets at once - expense_sheets = expense_sheet1 | expense_sheet2 - with self.assertRaisesRegex( - exceptions.UserError, "cannot register a payments for Employees and Vendors" - ): - expense_sheets.action_register_payment() - - def test_split_expense_sheet(self): - """Test splitting an expense sheet with multiple lines into - separate expense sheets with one line each""" - # Create sheet with two lines - expense_sheet = self.create_expense_report( - { - "name": "SPLIT SHEET", - "expense_line_ids": [ - Command.create( - { - "name": "SPLIT EXPENSE 1", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 100.00, - } - ), - Command.create( - { - "name": "SPLIT EXPENSE 2", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 200.00, - } - ), - ], - } - ) - expense_sheet.action_submit_sheet() - self.assertEqual(expense_sheet.state, "submit") - # Split expense sheet - action = expense_sheet._action_split_expenses() - splitted_sheets = self.env["hr.expense.sheet"].search(action["domain"]) - self.assertEqual(len(splitted_sheets), 2) - # State is preserved - self.assertEqual(splitted_sheets.mapped("state"), ["submit", "submit"]) - - def test_cannot_split_expense_sheet(self): - """Test can't splitting an expense sheet if state is not allowed""" - # Create sheet with two lines - expense_sheet = self.create_expense_report( - { - "name": "SPLIT SHEET", - "expense_line_ids": [ - Command.create( - { - "name": "SPLIT EXPENSE 1", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 100.00, - } - ), - Command.create( - { - "name": "SPLIT EXPENSE 2", - "payment_mode": "own_account", - "employee_id": self.expense_employee.id, - "product_id": self.product_c.id, - "total_amount": 200.00, - } - ), - ], - } - ) - expense_sheet.action_submit_sheet() - expense_sheet.action_approve_expense_sheets() - self._force_approve_with_draft_move(expense_sheet) - # Split the sheet with raises - with self.assertRaisesRegex(exceptions.UserError, "can be splitted"): - expense_sheet._action_split_expenses() diff --git a/hr_expense_employee_payment/views/hr_expense_views.xml b/hr_expense_employee_payment/views/hr_expense_views.xml new file mode 100644 index 000000000..931466013 --- /dev/null +++ b/hr_expense_employee_payment/views/hr_expense_views.xml @@ -0,0 +1,35 @@ + + + + hr.expense.view.form + hr.expense + + + + + + +