From 3f85812beeae7d539e6ffa5292abc83b52b702c3 Mon Sep 17 00:00:00 2001 From: Eduardo de Miguel Date: Mon, 24 Nov 2025 14:54:57 +0100 Subject: [PATCH] [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 + + + + + + + + + +