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 000000000..3a0328b51 Binary files /dev/null and b/hr_expense_employee_payment/static/description/icon.png differ 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 + + + + + + + + + +