From e36ecd0f84332fe3cdb1545797150e1a4b3dbe69 Mon Sep 17 00:00:00 2001 From: Maxime Chambreuil Date: Fri, 19 Jun 2026 08:38:53 -0600 Subject: [PATCH] [ADD] hr_expense_vendor_bill Add module moved from OCA/l10n-mexico#78 to create vendor bills from employee expenses paid on own account, register supplier payments, and generate a reconciled employee reimbursement bill. Only runs the custom flow when expense lines have a vendor set, so standard own-account expense sheets keep default hr-expense behavior. Includes unit tests for posting, payments, reconciliation, and error paths. Co-authored-by: Cursor --- hr_expense_vendor_bill/README.rst | 116 +++ hr_expense_vendor_bill/__init__.py | 1 + hr_expense_vendor_bill/__manifest__.py | 26 + .../demo/hr_expense_demo.xml | 22 + .../demo/res_company_demo.xml | 15 + .../demo/res_partner_demo.xml | 7 + hr_expense_vendor_bill/i18n/es.po | 233 ++++++ .../i18n/hr_expense_vendor_bill.pot | 218 +++++ hr_expense_vendor_bill/models/__init__.py | 5 + hr_expense_vendor_bill/models/account_move.py | 27 + hr_expense_vendor_bill/models/hr_expense.py | 11 + .../models/hr_expense_sheet.py | 396 ++++++++++ hr_expense_vendor_bill/models/res_company.py | 18 + .../models/res_config_settings.py | 18 + hr_expense_vendor_bill/pyproject.toml | 3 + hr_expense_vendor_bill/readme/CONFIGURE.md | 8 + hr_expense_vendor_bill/readme/CONTRIBUTORS.md | 5 + hr_expense_vendor_bill/readme/DESCRIPTION.md | 4 + hr_expense_vendor_bill/readme/USAGE.md | 15 + .../static/description/index.html | 464 +++++++++++ hr_expense_vendor_bill/tests/__init__.py | 1 + .../tests/test_hr_expense_vendor_bill.py | 742 ++++++++++++++++++ .../views/account_move_views.xml | 12 + .../views/hr_expense_sheet_inherit_views.xml | 15 + .../views/hr_expense_views.xml | 13 + .../views/res_config_settings_views.xml | 30 + 26 files changed, 2425 insertions(+) create mode 100644 hr_expense_vendor_bill/README.rst create mode 100644 hr_expense_vendor_bill/__init__.py create mode 100644 hr_expense_vendor_bill/__manifest__.py create mode 100644 hr_expense_vendor_bill/demo/hr_expense_demo.xml create mode 100644 hr_expense_vendor_bill/demo/res_company_demo.xml create mode 100644 hr_expense_vendor_bill/demo/res_partner_demo.xml create mode 100644 hr_expense_vendor_bill/i18n/es.po create mode 100644 hr_expense_vendor_bill/i18n/hr_expense_vendor_bill.pot create mode 100644 hr_expense_vendor_bill/models/__init__.py create mode 100644 hr_expense_vendor_bill/models/account_move.py create mode 100644 hr_expense_vendor_bill/models/hr_expense.py create mode 100644 hr_expense_vendor_bill/models/hr_expense_sheet.py create mode 100644 hr_expense_vendor_bill/models/res_company.py create mode 100644 hr_expense_vendor_bill/models/res_config_settings.py create mode 100644 hr_expense_vendor_bill/pyproject.toml create mode 100644 hr_expense_vendor_bill/readme/CONFIGURE.md create mode 100644 hr_expense_vendor_bill/readme/CONTRIBUTORS.md create mode 100644 hr_expense_vendor_bill/readme/DESCRIPTION.md create mode 100644 hr_expense_vendor_bill/readme/USAGE.md create mode 100644 hr_expense_vendor_bill/static/description/index.html create mode 100644 hr_expense_vendor_bill/tests/__init__.py create mode 100644 hr_expense_vendor_bill/tests/test_hr_expense_vendor_bill.py create mode 100644 hr_expense_vendor_bill/views/account_move_views.xml create mode 100644 hr_expense_vendor_bill/views/hr_expense_sheet_inherit_views.xml create mode 100644 hr_expense_vendor_bill/views/hr_expense_views.xml create mode 100644 hr_expense_vendor_bill/views/res_config_settings_views.xml diff --git a/hr_expense_vendor_bill/README.rst b/hr_expense_vendor_bill/README.rst new file mode 100644 index 000000000..abb6412c3 --- /dev/null +++ b/hr_expense_vendor_bill/README.rst @@ -0,0 +1,116 @@ +====================== +HR Expense Vendor Bill +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a3212290485c41bc5c7bd0a8167fc699612b4a097fdc9e66aa9b3b4f4981a003 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--expense-lightgray.png?logo=github + :target: https://github.com/OCA/hr-expense/tree/18.0/hr_expense_vendor_bill + :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_vendor_bill + :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 employee expenses paid with the employee's own +account to automatically create vendor bills grouped by supplier, +register supplier payments, and generate an employee reimbursement bill +that is reconciled with those payments. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Before using the module, configure the reimbursement accounts on the +company: + +1. Go to *Expenses > Configuration > Settings*. +2. Set the **Employee reimbursement debit account**. +3. Set the **Miscellaneous payables - employees** account. + +Optionally, create a bank journal named *Employee payments* to register +supplier payments during expense report posting. + +Usage +===== + +For expenses paid by the employee: + +1. Create an expense and set the **Vendor** on each expense line. +2. Submit and approve the expense report. +3. Post the expense report. + +The module will: + +- Create one draft vendor bill per supplier found on the expense lines. +- Post those vendor bills and register outbound payments. +- Create and post an employee reimbursement vendor bill. +- Reconcile the reimbursement with the supplier payments. + +When resetting an approved expense report to draft, related journal +entries and payments are removed automatically. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Gray Matter Logic + +Contributors +------------ + +- `Gray Matter Logic `__: + + - Diego Vega + - Samuel Macias Oropeza + - Hector del Reguero + - Maxime Chambreuil + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/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_vendor_bill/__init__.py b/hr_expense_vendor_bill/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/hr_expense_vendor_bill/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_expense_vendor_bill/__manifest__.py b/hr_expense_vendor_bill/__manifest__.py new file mode 100644 index 000000000..28d570a54 --- /dev/null +++ b/hr_expense_vendor_bill/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2025 Gray Matter Logic +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR Expense Vendor Bill", + "summary": "Create vendor bills from employee expenses paid on own account", + "author": "Gray Matter Logic, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/hr-expense", + "version": "18.0.1.0.0", + "category": "Human Resources/Expenses", + "depends": ["account", "hr_expense"], + "data": [ + "views/res_config_settings_views.xml", + "views/hr_expense_views.xml", + "views/account_move_views.xml", + "views/hr_expense_sheet_inherit_views.xml", + ], + "demo": [ + "demo/res_company_demo.xml", + "demo/res_partner_demo.xml", + "demo/hr_expense_demo.xml", + ], + "installable": True, + "application": False, + "license": "AGPL-3", +} diff --git a/hr_expense_vendor_bill/demo/hr_expense_demo.xml b/hr_expense_vendor_bill/demo/hr_expense_demo.xml new file mode 100644 index 000000000..9077cbcae --- /dev/null +++ b/hr_expense_vendor_bill/demo/hr_expense_demo.xml @@ -0,0 +1,22 @@ + + + + Employee vendor expense (demo) + + own_account + + + + Hotel paid by employee + + + + + own_account + + + + diff --git a/hr_expense_vendor_bill/demo/res_company_demo.xml b/hr_expense_vendor_bill/demo/res_company_demo.xml new file mode 100644 index 000000000..4012e2a12 --- /dev/null +++ b/hr_expense_vendor_bill/demo/res_company_demo.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/hr_expense_vendor_bill/demo/res_partner_demo.xml b/hr_expense_vendor_bill/demo/res_partner_demo.xml new file mode 100644 index 000000000..bff065f8c --- /dev/null +++ b/hr_expense_vendor_bill/demo/res_partner_demo.xml @@ -0,0 +1,7 @@ + + + + Demo Expense Vendor + 1 + + diff --git a/hr_expense_vendor_bill/i18n/es.po b/hr_expense_vendor_bill/i18n/es.po new file mode 100644 index 000000000..bed55050a --- /dev/null +++ b/hr_expense_vendor_bill/i18n/es.po @@ -0,0 +1,233 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_vendor_bill +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Maxime Chambreuil \n" +"Language-Team: Spanish\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" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_bank_statement_line__expense_sheet_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_move__expense_sheet_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_payment__expense_sheet_id +#: model:ir.model,name:hr_expense_vendor_bill.model_hr_expense_sheet +msgid "Expense Report" +msgstr "Reporte de gastos" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_bank_statement_line__expense_sheet_id +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_move__expense_sheet_id +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_payment__expense_sheet_id +msgid "Expense sheet from which this vendor bill was created." +msgstr "Hoja de gastos de la que se originó esta factura de proveedor." + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_bank_statement_line__is_employee_reimbursement +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_move__is_employee_reimbursement +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_payment__is_employee_reimbursement +msgid "Employee reimbursement" +msgstr "Reembolso de empleado" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_bank_statement_line__is_employee_reimbursement +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_move__is_employee_reimbursement +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_payment__is_employee_reimbursement +msgid "Marks vendor bills created by hr_expense_vendor_bill." +msgstr "Marca las facturas creadas por hr_expense_vendor_bill." + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_hr_expense__vendor_id +msgid "Vendor" +msgstr "Proveedor" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_hr_expense__vendor_id +msgid "Vendor from which this expense originates." +msgstr "Proveedor del cual proviene este gasto." + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_debit_account_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_config_settings__hr_expense_reimbursement_debit_account_id +msgid "Employee reimbursement debit account" +msgstr "Cuenta débito reembolso empleado" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_debit_account_id +msgid "Account used for the debit line on employee reimbursement bills." +msgstr "Cuenta para el débito en la factura de reembolso al empleado." + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_credit_account_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_config_settings__hr_expense_reimbursement_credit_account_id +msgid "Miscellaneous payables - employees" +msgstr "Cuenta acreedores diversos – empleados" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_credit_account_id +msgid "Account used for the credit line on employee reimbursement bills." +msgstr "Cuenta para el crédito en la factura de reembolso al empleado." + +#. module: hr_expense_vendor_bill +#: model_terms:ir.ui.view,arch_db:hr_expense_vendor_bill.view_hr_expense_settings_inherit +msgid "Account used to debit employee reimbursements." +msgstr "Cuenta contable para cargar el reembolso al empleado." + +#. module: hr_expense_vendor_bill +#: model_terms:ir.ui.view,arch_db:hr_expense_vendor_bill.view_hr_expense_settings_inherit +msgid "Account used to credit employee reimbursements." +msgstr "Cuenta contable para abonar a empleados." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Expense from %s" +msgstr "Gasto de %s" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Expenses %s" +msgstr "Gastos %s" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Configure at least one bank journal to register vendor payments." +msgstr "" +"Debe configurar al menos un diario bancario para registrar pagos de " +"proveedores." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Journal '%s' has no payment method and the manual method does not exist." +msgstr "" +"El diario '%s' no tiene método de pago y no existe el método manual." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Configure the debit and credit accounts for employee reimbursements in " +"Settings > Companies." +msgstr "" +"Debes configurar en Ajustes > Empresas las cuentas de débito y crédito " +"para el reembolso de empleados." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Employee %s has no work contact configured." +msgstr "El empleado %s no tiene un contacto laboral configurado." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "No journal is defined on the expense sheet." +msgstr "No se ha definido un diario en la hoja de gastos." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Expense reimbursement" +msgstr "Reembolso de gastos" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Reimbursement %s" +msgstr "Reembolso %s" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Configure the reconciliation account in Accounting > Configuration > " +"Companies." +msgstr "" +"Configurar en Contabilidad > Configuración > Empresas la cuenta para " +"poder conciliar." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"There are no journal entries to post. Make sure the expense report is " +"approved." +msgstr "" +"No hay movimientos contables para contabilizar. Asegúrate de que la hoja " +"de gastos esté aprobada." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Cannot reset expense sheet '%(sheet)s'. Failed to unpost journal entry " +"'%(move)s': %(e)s" +msgstr "" +"No se puede restablecer el reporte de gastos '%(sheet)s'. Error al " +"despublicar el asiento contable '%(move)s': %(e)s" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Cannot delete journal entries: %s" +msgstr "No se pueden eliminar los asientos contables: %s" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Expense sheet has been reset to draft." +msgstr "El reporte de gastos se restableció a borrador." + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Reset to Draft" +msgstr "Restablecer a borrador" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_account_move +msgid "Journal Entry" +msgstr "Asiento contable" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_hr_expense +msgid "Expense" +msgstr "Gasto" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: hr_expense_vendor_bill +#: model:ir.module.module,shortdesc:hr_expense_vendor_bill.module_meta_information +msgid "HR Expense Vendor Bill" +msgstr "Factura de proveedor desde gastos" + +#. module: hr_expense_vendor_bill +#: model:ir.module.module,summary:hr_expense_vendor_bill.module_meta_information +msgid "Create vendor bills from employee expenses paid on own account" +msgstr "" +"Genera automáticamente facturas de proveedor a partir de gastos de " +"empleados" diff --git a/hr_expense_vendor_bill/i18n/hr_expense_vendor_bill.pot b/hr_expense_vendor_bill/i18n/hr_expense_vendor_bill.pot new file mode 100644 index 000000000..66b24cbf6 --- /dev/null +++ b/hr_expense_vendor_bill/i18n/hr_expense_vendor_bill.pot @@ -0,0 +1,218 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_vendor_bill +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \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_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_bank_statement_line__expense_sheet_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_move__expense_sheet_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_payment__expense_sheet_id +msgid "Expense Report" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_bank_statement_line__expense_sheet_id +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_move__expense_sheet_id +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_payment__expense_sheet_id +msgid "Expense sheet from which this vendor bill was created." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_bank_statement_line__is_employee_reimbursement +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_move__is_employee_reimbursement +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_account_payment__is_employee_reimbursement +msgid "Employee reimbursement" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_bank_statement_line__is_employee_reimbursement +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_move__is_employee_reimbursement +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_account_payment__is_employee_reimbursement +msgid "Marks vendor bills created by hr_expense_vendor_bill." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_hr_expense__vendor_id +msgid "Vendor" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_hr_expense__vendor_id +msgid "Vendor from which this expense originates." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_debit_account_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_config_settings__hr_expense_reimbursement_debit_account_id +msgid "Employee reimbursement debit account" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_debit_account_id +msgid "Account used for the debit line on employee reimbursement bills." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_credit_account_id +#: model:ir.model.fields,field_description:hr_expense_vendor_bill.field_res_config_settings__hr_expense_reimbursement_credit_account_id +msgid "Miscellaneous payables - employees" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model.fields,help:hr_expense_vendor_bill.field_res_company__hr_expense_reimbursement_credit_account_id +msgid "Account used for the credit line on employee reimbursement bills." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model_terms:ir.ui.view,arch_db:hr_expense_vendor_bill.view_hr_expense_settings_inherit +msgid "Account used to debit employee reimbursements." +msgstr "" + +#. module: hr_expense_vendor_bill +#: model_terms:ir.ui.view,arch_db:hr_expense_vendor_bill.view_hr_expense_settings_inherit +msgid "Account used to credit employee reimbursements." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Expense from %s" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Expenses %s" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Configure at least one bank journal to register vendor payments." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Journal '%s' has no payment method and the manual method does not exist." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Configure the debit and credit accounts for employee reimbursements in " +"Settings > Companies." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Employee %s has no work contact configured." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "No journal is defined on the expense sheet." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Expense reimbursement" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Reimbursement %s" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Configure the reconciliation account in Accounting > Configuration > " +"Companies." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"There are no journal entries to post. Make sure the expense report is " +"approved." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "" +"Cannot reset expense sheet '%(sheet)s'. Failed to unpost journal entry " +"'%(move)s': %(e)s" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +#, python-format +msgid "Cannot delete journal entries: %s" +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Expense sheet has been reset to draft." +msgstr "" + +#. module: hr_expense_vendor_bill +#. odoo-python +#: code:addons/hr_expense_vendor_bill/models/hr_expense_sheet.py:0 +msgid "Reset to Draft" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_hr_expense +msgid "Expense" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_res_company +msgid "Companies" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.model,name:hr_expense_vendor_bill.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.module.module,shortdesc:hr_expense_vendor_bill.module_meta_information +msgid "HR Expense Vendor Bill" +msgstr "" + +#. module: hr_expense_vendor_bill +#: model:ir.module.module,summary:hr_expense_vendor_bill.module_meta_information +msgid "Create vendor bills from employee expenses paid on own account" +msgstr "" diff --git a/hr_expense_vendor_bill/models/__init__.py b/hr_expense_vendor_bill/models/__init__.py new file mode 100644 index 000000000..cb7cae814 --- /dev/null +++ b/hr_expense_vendor_bill/models/__init__.py @@ -0,0 +1,5 @@ +from . import account_move +from . import hr_expense_sheet +from . import hr_expense +from . import res_company +from . import res_config_settings diff --git a/hr_expense_vendor_bill/models/account_move.py b/hr_expense_vendor_bill/models/account_move.py new file mode 100644 index 000000000..8ae70e91f --- /dev/null +++ b/hr_expense_vendor_bill/models/account_move.py @@ -0,0 +1,27 @@ +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + expense_sheet_id = fields.Many2one( + "hr.expense.sheet", + string="Expense Report", + help="Expense sheet from which this vendor bill was created.", + ondelete="set null", + ) + + is_employee_reimbursement = fields.Boolean( + string="Employee reimbursement", + default=False, + help="Marks vendor bills created by hr_expense_vendor_bill.", + ) + + def _compute_payment_state(self): + res = super()._compute_payment_state() + + reembolsos = self.filtered(lambda m: m.is_employee_reimbursement) + for move in reembolsos: + move.payment_state = "not_paid" + + return res diff --git a/hr_expense_vendor_bill/models/hr_expense.py b/hr_expense_vendor_bill/models/hr_expense.py new file mode 100644 index 000000000..4cd167c86 --- /dev/null +++ b/hr_expense_vendor_bill/models/hr_expense.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + vendor_id = fields.Many2one( + "res.partner", + string="Vendor", + help="Vendor from which this expense originates.", + ) diff --git a/hr_expense_vendor_bill/models/hr_expense_sheet.py b/hr_expense_vendor_bill/models/hr_expense_sheet.py new file mode 100644 index 000000000..4b439678e --- /dev/null +++ b/hr_expense_vendor_bill/models/hr_expense_sheet.py @@ -0,0 +1,396 @@ +from collections import defaultdict + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class HrExpenseSheet(models.Model): + _inherit = "hr.expense.sheet" + + def _is_vendor_bill_sheet(self): + self.ensure_one() + return self.payment_mode == "own_account" and bool( + self.expense_line_ids.filtered("vendor_id") + ) + + def _create_supplier_invoices(self): + invoices = self.env["account.move"] + for sheet in self: + # Agrupar gastos por proveedor + expenses_by_supplier = defaultdict(list) + for exp in sheet.expense_line_ids: + if exp.vendor_id: + expenses_by_supplier[exp.vendor_id].append(exp) + + # Para cada proveedor, generar una factura + for supplier, expenses in expenses_by_supplier.items(): + inv_date = fields.Date.context_today(self) + invoice_lines = [] + + for exp in expenses: + # Calcular precio sin impuestos (subtotal) y asignar impuestos + net_amount = exp.total_amount - exp.tax_amount + invoice_lines.append( + ( + 0, + 0, + { + "name": exp.name + or _("Expense from %s") % supplier.name, + "account_id": exp.account_id.id, + "quantity": 1.0, + "price_unit": net_amount, + "tax_ids": [(6, 0, exp.tax_ids.ids)], + }, + ) + ) + + # Encabezado de la factura de proveedor + inv_vals = { + "move_type": "in_invoice", + "partner_id": supplier.id, + "invoice_date": inv_date, + "invoice_date_due": inv_date, + "journal_id": sheet.journal_id.id, + "ref": _("Expenses %s") % sheet.name, + "expense_sheet_id": sheet.id, + "invoice_line_ids": invoice_lines, + "state": "draft", + } + invoice = self.env["account.move"].create(inv_vals) + invoices |= invoice + + return invoices + + def _generate_supplier_payments(self): + PaymentRegister = self.env["account.payment.register"] + + for sheet in self: + pay_journal = self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ("name", "ilike", "Employee payments"), + ("company_id", "=", sheet.company_id.id), + ], + limit=1, + ) or self.env["account.journal"].search( + [("type", "=", "bank"), ("company_id", "=", sheet.company_id.id)], + limit=1, + ) + if not pay_journal: + raise UserError( + _( + "Configure at least one bank journal " + "to register vendor payments." + ) + ) + + if pay_journal.outbound_payment_method_line_ids: + pm_line = pay_journal.outbound_payment_method_line_ids[:1] + else: + manual = self.env.ref( + "account.account_payment_method_manual_out", + raise_if_not_found=False, + ) + if not manual: + raise UserError( + _( + "Journal '%s' has no payment method " + "and the manual method does not exist." + ) + % pay_journal.name + ) + pm_line = self.env["account.payment.method.line"].create( + { + "journal_id": pay_journal.id, + "payment_method_id": manual.id, + "payment_type": "outbound", + "code": manual.code or "manual", + "name": manual.name or _("Manual"), + } + ) + + employee_contact = sheet.employee_id.sudo().work_contact_id + invoices_to_pay = sheet.account_move_ids.filtered( + lambda m, employee_contact=employee_contact: ( + m.move_type == "in_invoice" + and m.partner_id != employee_contact + and m.payment_state != "paid" + ) + ) + + for invoice in invoices_to_pay: + if invoice.amount_residual > 0: + context = { + "active_model": "account.move", + "active_ids": [invoice.id], + "active_id": invoice.id, + } + wizard = PaymentRegister.with_context(**context).create( + { + "journal_id": pay_journal.id, + "payment_method_line_id": pm_line.id, + "amount": invoice.amount_residual, + "payment_date": fields.Date.context_today(sheet), + } + ) + wizard.action_create_payments() + + def _create_employee_reimbursement_invoice(self): + AccountMove = self.env["account.move"] + invoices = self.env["account.move"] + + company = self.company_id + acc_debit = company.hr_expense_reimbursement_debit_account_id + acc_credit = company.hr_expense_reimbursement_credit_account_id + if not acc_debit or not acc_credit: + raise UserError( + _( + "Configure the debit and credit accounts for employee " + "reimbursements in Settings > Companies." + ) + ) + + for sheet in self: + total_amount = sum(exp.total_amount for exp in sheet.expense_line_ids) + if total_amount <= 0: + continue + + partner = sheet.employee_id.sudo().work_contact_id + if not partner: + raise UserError( + _("Employee %s has no work contact configured.") + % sheet.employee_id.name + ) + partner = partner.with_company(sheet.company_id) + + inv_date = fields.Date.context_today(sheet) + + journal = sheet.journal_id + if not journal: + raise UserError(_("No journal is defined on the expense sheet.")) + + invoice_lines = [ + ( + 0, + 0, + { + "name": _("Expense reimbursement"), + "account_id": acc_debit.id, + "quantity": 1.0, + "price_unit": total_amount, + "tax_ids": [], + }, + ) + ] + + move_vals = { + "move_type": "in_invoice", + "partner_id": partner.id, + "invoice_date": inv_date, + "invoice_date_due": inv_date, + "ref": _("Reimbursement %s") % sheet.name, + "journal_id": journal.id, + "invoice_line_ids": invoice_lines, + "state": "draft", + } + + move = AccountMove.create(move_vals) + # Don't post the move here - let action_sheet_move_post handle it + sheet.write({"account_move_ids": [(4, move.id)]}) + invoices |= move + + return invoices + + def _reconcile_account_lines(self): + for sheet in self: + account = sheet.company_id.hr_expense_reimbursement_credit_account_id + if not account: + raise UserError( + _( + "Configure the reconciliation account in " + "Accounting > Configuration > Companies." + ) + ) + move_lines = self.env["account.move.line"].search( + [ + ("move_id", "in", sheet.account_move_ids.ids), + ("account_id", "=", account.id), + ("reconciled", "=", False), + ] + ) + if len(move_lines) >= 2: + move_lines.reconcile() + + def action_approve_expense_sheets(self): + # Método original para aprobar hojas de gasto + res = super().action_approve_expense_sheets() + + for sheet in self.filtered(lambda s: s._is_vendor_bill_sheet()): + moves = sheet.account_move_ids.sudo() + for move in moves: + if move.state == "posted": + move.sudo().button_cancel() + move.sudo().button_draft() + elif move.state == "cancel": + move.sudo().button_draft() + moves.unlink() + sheet.sudo().write({"account_move_ids": [(5, 0, 0)]}) + + sheet._create_supplier_invoices() + sheet._create_employee_reimbursement_invoice() + return res + + def action_sheet_move_post(self): + # Separate own_account sheets that need custom posting + own_account_sheets = self.filtered(lambda sheet: sheet._is_vendor_bill_sheet()) + standard_sheets = self - own_account_sheets + + # Handle own_account sheets with custom logic + for sheet in own_account_sheets: + # Ensure moves exist (in case method is called without approval) + if not sheet.account_move_ids: + raise UserError( + _( + "There are no journal entries to post. " + "Make sure the expense sheet is approved." + ) + ) + + employee_contact = sheet.employee_id.sudo().work_contact_id + # Separate employee reimbursement from supplier invoices + supplier_invoices = sheet.account_move_ids.filtered( + lambda m, employee_contact=employee_contact: ( + m.move_type == "in_invoice" + and m.state == "draft" + and m.partner_id != employee_contact + ) + ) + + employee_reimbursement = sheet.account_move_ids.filtered( + lambda m, employee_contact=employee_contact: ( + m.move_type == "in_invoice" + and m.state == "draft" + and m.partner_id == employee_contact + ) + ) + + # Step 1: Post supplier invoices + supplier_invoices.action_post() + + # Step 2: Generate payments for posted supplier invoices + sheet._generate_supplier_payments() + + # Step 3: Post employee reimbursement invoice + employee_reimbursement.action_post() + + # Step 4: Reconcile everything + sheet._reconcile_account_lines() + + # Step 5: Change sheet state to done (or final state) + sheet.write({"state": "done"}) + + # Handle standard sheets using parent method + if standard_sheets: + super(HrExpenseSheet, standard_sheets).action_sheet_move_post() + + return True + + def action_open_account_moves(self): + self.ensure_one() + # Prepara la acción para mostrar los movimientos contables relacionados + action = { + "type": "ir.actions.act_window", + "res_model": "account.move", + "views": [(False, "list"), (False, "form")], + "domain": [("id", "in", self.account_move_ids.ids)], + "context": {"default_expense_sheet_id": self.id}, + } + # Si solo hay un movimiento, abre directamente el formulario + if len(self.account_move_ids) == 1: + action.update( + { + "view_mode": "form", + "res_id": self.account_move_ids.id, + } + ) + return action + + # El metodo borrar los asientos contables al regresar la hoja de gastos a borrador + def action_reset_expense_sheets(self): + """ + Override to properly handle accounting entries when resetting to draft. + This ensures posted journal entries are cancelled and removed. + """ + sheets_with_moves = self.filtered( + lambda s: s._is_vendor_bill_sheet() and s.account_move_ids + ) + + if sheets_with_moves: + for sheet in sheets_with_moves: + moves = sheet.account_move_ids + + # First: Reset all moves to draft + for move in moves: + if not move: + continue + + # Store move name for audit trail + move_name = move.name or "Draft" + move_state = move.state + + # Handle posted moves + if move_state == "posted": + # In Odoo 18, use button_draft to unpost + try: + if sheet.payment_mode == "own_account": + move.button_draft() + else: + move.payment_ids.action_draft() + + except Exception as e: + raise UserError( + _( + "Cannot reset expense sheet '%(sheet)s'. " + "Failed to unpost journal entry '%(move)s': %(e)s" + ) + % {"sheet": sheet.name, "move": move_name, "e": str(e)} + ) from e + + for move in moves: + for line in move.line_ids: + if line.matched_debit_ids or line.matched_credit_ids: + # Remove reconciliations from debit and credit sides + line.matched_debit_ids.unlink() + line.matched_credit_ids.unlink() + + # Second: Delete all moves in one action after they're reset to draft + try: + if sheet.payment_mode == "own_account": + moves.payment_ids.unlink() + moves.unlink() + else: + moves.payment_ids.unlink() + + except Exception as e: + raise UserError( + _("Cannot delete journal entries: %s") % str(e) + ) from None + + # Clear the reference after processing all moves for this sheet + sheet.write({"account_move_ids": False}) + + # Call parent method to execute original reset logic + res = super().action_reset_expense_sheets() + + # Additional cleanup: Clear accounting date + self.write({"accounting_date": False}) + + # Post message about reset + for sheet in self: + sheet.message_post( + body=_("Expense sheet has been reset to draft."), + subject=_("Reset to Draft"), + ) + + return res diff --git a/hr_expense_vendor_bill/models/res_company.py b/hr_expense_vendor_bill/models/res_company.py new file mode 100644 index 000000000..e7e1c336f --- /dev/null +++ b/hr_expense_vendor_bill/models/res_company.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + hr_expense_reimbursement_debit_account_id = fields.Many2one( + "account.account", + string="Employee reimbursement debit account", + domain=[("deprecated", "=", False)], + help="Account used for the debit line on employee reimbursement bills.", + ) + hr_expense_reimbursement_credit_account_id = fields.Many2one( + "account.account", + string="Miscellaneous payables - employees", + domain=[("deprecated", "=", False)], + help="Account used for the credit line on employee reimbursement bills.", + ) diff --git a/hr_expense_vendor_bill/models/res_config_settings.py b/hr_expense_vendor_bill/models/res_config_settings.py new file mode 100644 index 000000000..084a18f4a --- /dev/null +++ b/hr_expense_vendor_bill/models/res_config_settings.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + hr_expense_reimbursement_debit_account_id = fields.Many2one( + "account.account", + related="company_id.hr_expense_reimbursement_debit_account_id", + string="Employee reimbursement debit account", + readonly=False, + ) + hr_expense_reimbursement_credit_account_id = fields.Many2one( + "account.account", + related="company_id.hr_expense_reimbursement_credit_account_id", + string="Miscellaneous payables - employees", + readonly=False, + ) diff --git a/hr_expense_vendor_bill/pyproject.toml b/hr_expense_vendor_bill/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_vendor_bill/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_vendor_bill/readme/CONFIGURE.md b/hr_expense_vendor_bill/readme/CONFIGURE.md new file mode 100644 index 000000000..9803c4236 --- /dev/null +++ b/hr_expense_vendor_bill/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +Before using the module, configure the reimbursement accounts on the company: + +1. Go to *Expenses > Configuration > Settings*. +2. Set the **Employee reimbursement debit account**. +3. Set the **Miscellaneous payables - employees** account. + +Optionally, create a bank journal named *Employee payments* to register +supplier payments during expense report posting. diff --git a/hr_expense_vendor_bill/readme/CONTRIBUTORS.md b/hr_expense_vendor_bill/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..d208cfc7a --- /dev/null +++ b/hr_expense_vendor_bill/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [Gray Matter Logic](https://www.graymatterlogic.com): + - Diego Vega \<\> + - Samuel Macias Oropeza \<\> + - Hector del Reguero \<\> + - Maxime Chambreuil \<\> diff --git a/hr_expense_vendor_bill/readme/DESCRIPTION.md b/hr_expense_vendor_bill/readme/DESCRIPTION.md new file mode 100644 index 000000000..07eef46fe --- /dev/null +++ b/hr_expense_vendor_bill/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module extends employee expenses paid with the employee's own account +to automatically create vendor bills grouped by supplier, register supplier +payments, and generate an employee reimbursement bill that is reconciled +with those payments. diff --git a/hr_expense_vendor_bill/readme/USAGE.md b/hr_expense_vendor_bill/readme/USAGE.md new file mode 100644 index 000000000..aa58c669c --- /dev/null +++ b/hr_expense_vendor_bill/readme/USAGE.md @@ -0,0 +1,15 @@ +For expenses paid by the employee: + +1. Create an expense and set the **Vendor** on each expense line. +2. Submit and approve the expense report. +3. Post the expense report. + +The module will: + +- Create one draft vendor bill per supplier found on the expense lines. +- Post those vendor bills and register outbound payments. +- Create and post an employee reimbursement vendor bill. +- Reconcile the reimbursement with the supplier payments. + +When resetting an approved expense report to draft, related journal entries +and payments are removed automatically. diff --git a/hr_expense_vendor_bill/static/description/index.html b/hr_expense_vendor_bill/static/description/index.html new file mode 100644 index 000000000..861771af2 --- /dev/null +++ b/hr_expense_vendor_bill/static/description/index.html @@ -0,0 +1,464 @@ + + + + + +HR Expense Vendor Bill + + + +
+

HR Expense Vendor Bill

+ + +

Beta License: AGPL-3 OCA/hr-expense Translate me on Weblate Try me on Runboat

+

This module extends employee expenses paid with the employee’s own +account to automatically create vendor bills grouped by supplier, +register supplier payments, and generate an employee reimbursement bill +that is reconciled with those payments.

+

Table of contents

+ +
+

Configuration

+

Before using the module, configure the reimbursement accounts on the +company:

+
    +
  1. Go to Expenses > Configuration > Settings.
  2. +
  3. Set the Employee reimbursement debit account.
  4. +
  5. Set the Miscellaneous payables - employees account.
  6. +
+

Optionally, create a bank journal named Employee payments to register +supplier payments during expense report posting.

+
+
+

Usage

+

For expenses paid by the employee:

+
    +
  1. Create an expense and set the Vendor on each expense line.
  2. +
  3. Submit and approve the expense report.
  4. +
  5. Post the expense report.
  6. +
+

The module will:

+
    +
  • Create one draft vendor bill per supplier found on the expense lines.
  • +
  • Post those vendor bills and register outbound payments.
  • +
  • Create and post an employee reimbursement vendor bill.
  • +
  • Reconcile the reimbursement with the supplier payments.
  • +
+

When resetting an approved expense report to draft, related journal +entries and payments are removed automatically.

+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Gray Matter Logic
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/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_vendor_bill/tests/__init__.py b/hr_expense_vendor_bill/tests/__init__.py new file mode 100644 index 000000000..910865499 --- /dev/null +++ b/hr_expense_vendor_bill/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_expense_vendor_bill diff --git a/hr_expense_vendor_bill/tests/test_hr_expense_vendor_bill.py b/hr_expense_vendor_bill/tests/test_hr_expense_vendor_bill.py new file mode 100644 index 000000000..a78fe4a83 --- /dev/null +++ b/hr_expense_vendor_bill/tests/test_hr_expense_vendor_bill.py @@ -0,0 +1,742 @@ +# Copyright 2026 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.hr_expense.tests.common import TestExpenseCommon + + +@tagged("post_install", "-at_install") +class TestHrExpenseVendorBill(TestExpenseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.vendor = cls.env["res.partner"].create({"name": "Test Vendor"}) + cls.company = cls.company_data["company"] + cls.company.write( + { + "hr_expense_reimbursement_debit_account_id": cls.company_data[ + "default_account_expense" + ].id, + "hr_expense_reimbursement_credit_account_id": cls.company_data[ + "default_account_payable" + ].id, + } + ) + + def _create_own_account_sheet(self, total_amount=100.0): + return self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": total_amount, + "vendor_id": self.vendor.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + + def test_vendor_id_on_expense(self): + expense = self.create_expense({"vendor_id": self.vendor.id}) + self.assertEqual(expense.vendor_id, self.vendor) + + def test_create_supplier_invoices(self): + sheet = self._create_own_account_sheet() + invoices = sheet._create_supplier_invoices() + self.assertEqual(len(invoices), 1) + self.assertEqual(invoices.partner_id, self.vendor) + self.assertEqual(invoices.expense_sheet_id, sheet) + self.assertEqual(invoices.move_type, "in_invoice") + + def test_create_employee_reimbursement_invoice(self): + sheet = self._create_own_account_sheet(total_amount=250.0) + invoices = sheet._create_employee_reimbursement_invoice() + employee_partner = sheet.employee_id.sudo().work_contact_id + self.assertEqual(len(invoices), 1) + self.assertEqual(invoices.partner_id, employee_partner) + self.assertEqual(invoices.move_type, "in_invoice") + self.assertIn(sheet, invoices.expense_sheet_id) + + def test_reimbursement_payment_state(self): + move = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.expense_employee.work_contact_id.id, + "is_employee_reimbursement": True, + } + ) + move._compute_payment_state() + self.assertEqual(move.payment_state, "not_paid") + + def test_missing_reimbursement_accounts_raises(self): + self.company.write( + { + "hr_expense_reimbursement_debit_account_id": False, + "hr_expense_reimbursement_credit_account_id": False, + } + ) + sheet = self._create_own_account_sheet() + with self.assertRaises(UserError): + sheet._create_employee_reimbursement_invoice() + + def test_approve_own_account_creates_invoices(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + employee_partner = sheet.employee_id.sudo().work_contact_id + supplier_invoices = sheet.account_move_ids.filtered( + lambda move: move.partner_id == self.vendor + ) + employee_invoices = sheet.account_move_ids.filtered( + lambda move: move.partner_id == employee_partner + ) + self.assertEqual(len(supplier_invoices), 1) + self.assertEqual(len(employee_invoices), 1) + self.assertEqual(supplier_invoices.expense_sheet_id, sheet) + + def test_create_supplier_invoices_without_vendor(self): + sheet = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 100.0, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + invoices = sheet._create_supplier_invoices() + self.assertFalse(invoices) + + def test_action_sheet_move_post_without_moves_raises(self): + sheet = self._create_own_account_sheet() + with self.assertRaises(UserError): + sheet.action_sheet_move_post() + + def test_action_sheet_move_post(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + self.assertEqual(sheet.state, "done") + posted_moves = sheet.account_move_ids.filtered( + lambda move: move.state == "posted" + ) + self.assertTrue(posted_moves) + + def test_action_reset_expense_sheets_clears_moves(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + sheet.action_reset_expense_sheets() + self.assertFalse(sheet.account_move_ids) + self.assertFalse(sheet.accounting_date) + + def test_action_open_account_moves(self): + sheet = self._create_own_account_sheet() + invoice = sheet._create_supplier_invoices() + action = sheet.action_open_account_moves() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "account.move") + self.assertEqual(action["res_id"], invoice.id) + self.assertEqual(action["view_mode"], "form") + + def test_action_open_account_moves_multiple(self): + sheet = self._create_own_account_sheet() + sheet._create_supplier_invoices() + sheet._create_employee_reimbursement_invoice() + action = sheet.action_open_account_moves() + self.assertEqual(len(sheet.account_move_ids), 2) + self.assertNotIn("res_id", action) + + def test_res_config_settings_reimbursement_accounts(self): + settings = self.env["res.config.settings"].create( + { + "company_id": self.company.id, + "hr_expense_reimbursement_debit_account_id": self.company_data[ + "default_account_expense" + ].id, + "hr_expense_reimbursement_credit_account_id": self.company_data[ + "default_account_payable" + ].id, + } + ) + settings.execute() + self.assertEqual( + self.company.hr_expense_reimbursement_debit_account_id, + self.company_data["default_account_expense"], + ) + self.assertEqual( + self.company.hr_expense_reimbursement_credit_account_id, + self.company_data["default_account_payable"], + ) + + def test_approve_company_account_does_not_create_vendor_invoices(self): + sheet = self.create_expense_report( + { + "payment_mode": "company_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 1000.00, + "tax_ids": [Command.set(self.tax_purchase_a.ids)], + "payment_mode": "company_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + self.assertFalse( + sheet.account_move_ids.filtered( + lambda move: move.move_type == "in_invoice" + and move.partner_id == self.vendor + ) + ) + + def test_action_sheet_move_post_company_account(self): + sheet = self.create_expense_report( + { + "payment_mode": "company_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 1000.00, + "tax_ids": [Command.set(self.tax_purchase_a.ids)], + "payment_mode": "company_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + self.assertTrue(sheet.account_move_ids) + + def test_action_reset_company_account_sheet(self): + sheet = self.create_expense_report( + { + "payment_mode": "company_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 1000.00, + "tax_ids": [Command.set(self.tax_purchase_a.ids)], + "payment_mode": "company_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + sheet.action_reset_expense_sheets() + self.assertFalse(sheet.accounting_date) + + def test_create_employee_reimbursement_without_work_contact(self): + employee = self.env["hr.employee"].create({"name": "No Contact Employee"}) + employee.work_contact_id = False + sheet = self.create_expense_report( + { + "employee_id": employee.id, + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 100.0, + "vendor_id": self.vendor.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + with self.assertRaises(UserError): + sheet._create_employee_reimbursement_invoice() + + def test_reconcile_without_credit_account_raises(self): + self.company.write({"hr_expense_reimbursement_credit_account_id": False}) + sheet = self._create_own_account_sheet() + with self.assertRaises(UserError): + sheet._reconcile_account_lines() + + def test_create_supplier_invoices_multiple_vendors(self): + vendor2 = self.env["res.partner"].create({"name": "Vendor 2"}) + sheet = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 100.0, + "vendor_id": self.vendor.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ), + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 50.0, + "vendor_id": vendor2.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ), + ], + } + ) + invoices = sheet._create_supplier_invoices() + self.assertEqual(len(invoices), 2) + self.assertEqual(set(invoices.mapped("partner_id")), {self.vendor, vendor2}) + + def test_create_employee_reimbursement_skips_zero_amount(self): + sheet = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 0.0, + "vendor_id": self.vendor.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + invoices = sheet._create_employee_reimbursement_invoice() + self.assertFalse(invoices) + + def test_account_move_expense_sheet_link(self): + sheet = self._create_own_account_sheet() + invoice = sheet._create_supplier_invoices() + self.assertEqual(invoice.expense_sheet_id, sheet) + + def test_is_vendor_bill_sheet(self): + sheet = self._create_own_account_sheet() + self.assertTrue(sheet._is_vendor_bill_sheet()) + sheet_no_vendor = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 100.0, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + self.assertFalse(sheet_no_vendor._is_vendor_bill_sheet()) + + def test_approve_own_account_without_vendor_skips_vendor_flow(self): + sheet = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 100.0, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + self.assertFalse( + sheet.account_move_ids.filtered( + lambda move: move.move_type == "in_invoice" + and move.partner_id == self.vendor + ) + ) + + def test_supplier_invoice_line_fallback_name(self): + sheet = self._create_own_account_sheet() + sheet.expense_line_ids.write({"name": ""}) + invoice = sheet._create_supplier_invoices() + self.assertIn(self.vendor.name, invoice.invoice_line_ids[0].name) + + def test_create_reimbursement_without_journal_raises(self): + sheet = self._create_own_account_sheet() + sheet.journal_id = False + with self.assertRaises(UserError): + sheet._create_employee_reimbursement_invoice() + + def test_missing_debit_account_raises(self): + self.company.write({"hr_expense_reimbursement_debit_account_id": False}) + sheet = self._create_own_account_sheet() + with self.assertRaises(UserError): + sheet._create_employee_reimbursement_invoice() + + def test_generate_supplier_payments(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + supplier_invoice = sheet.account_move_ids.filtered( + lambda move: move.partner_id == self.vendor + ) + supplier_invoice.action_post() + sheet._generate_supplier_payments() + self.assertTrue(supplier_invoice.payment_state in ("paid", "in_payment")) + + def test_generate_supplier_payments_uses_employee_payments_journal(self): + employee_journal = self.env["account.journal"].create( + { + "name": "Employee payments", + "type": "bank", + "code": "EPAY", + "company_id": self.company.id, + } + ) + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + supplier_invoice = sheet.account_move_ids.filtered( + lambda move: move.partner_id == self.vendor + ) + supplier_invoice.action_post() + sheet._generate_supplier_payments() + payments = self.env["account.payment"].search( + [ + ("journal_id", "=", employee_journal.id), + ("company_id", "=", self.company.id), + ] + ) + self.assertTrue(payments) + + def test_generate_supplier_payments_no_bank_journal_raises(self): + self.env["account.journal"].search( + [("type", "=", "bank"), ("company_id", "=", self.company.id)] + ).write({"active": False}) + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + supplier_invoice = sheet.account_move_ids.filtered( + lambda move: move.partner_id == self.vendor + ) + supplier_invoice.action_post() + with self.assertRaises(UserError): + sheet._generate_supplier_payments() + + def test_reconcile_account_lines_reconciles_matching_lines(self): + credit_account = self.company.hr_expense_reimbursement_credit_account_id + offset_account = self.company_data["default_account_expense"] + sheet = self._create_own_account_sheet() + move1 = self.env["account.move"].create( + { + "move_type": "entry", + "line_ids": [ + Command.create( + { + "account_id": credit_account.id, + "debit": 100.0, + "credit": 0.0, + "name": "Credit debit", + } + ), + Command.create( + { + "account_id": offset_account.id, + "debit": 0.0, + "credit": 100.0, + "name": "Offset credit", + } + ), + ], + } + ) + move2 = self.env["account.move"].create( + { + "move_type": "entry", + "line_ids": [ + Command.create( + { + "account_id": credit_account.id, + "debit": 0.0, + "credit": 100.0, + "name": "Credit credit", + } + ), + Command.create( + { + "account_id": offset_account.id, + "debit": 100.0, + "credit": 0.0, + "name": "Offset debit", + } + ), + ], + } + ) + move1.action_post() + move2.action_post() + sheet.write( + {"account_move_ids": [Command.link(move1.id), Command.link(move2.id)]} + ) + sheet._reconcile_account_lines() + credit_lines = (move1 | move2).line_ids.filtered( + lambda line: line.account_id == credit_account + ) + self.assertEqual(len(credit_lines), 2) + self.assertTrue(all(line.reconciled for line in credit_lines)) + + def test_account_move_payment_state_only_overrides_reimbursement(self): + reimbursement = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.expense_employee.work_contact_id.id, + "is_employee_reimbursement": True, + } + ) + vendor_bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.vendor.id, + "is_employee_reimbursement": False, + } + ) + (reimbursement | vendor_bill)._compute_payment_state() + self.assertEqual(reimbursement.payment_state, "not_paid") + self.assertTrue(vendor_bill.payment_state) + + def test_action_reset_posts_message(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + sheet.action_sheet_move_post() + message_count = len(sheet.message_ids) + sheet.action_reset_expense_sheets() + self.assertGreater(len(sheet.message_ids), message_count) + reset_messages = sheet.message_ids.filtered( + lambda message: "reset to draft" in (message.body or "").lower() + ) + self.assertTrue(reset_messages) + + def test_supplier_invoice_with_taxes(self): + sheet = self.create_expense_report( + { + "payment_mode": "own_account", + "expense_line_ids": [ + Command.create( + { + "employee_id": self.expense_employee.id, + "product_id": self.product_c.id, + "total_amount_currency": 115.0, + "tax_ids": [Command.set(self.tax_purchase_a.ids)], + "vendor_id": self.vendor.id, + "payment_mode": "own_account", + "date": self.frozen_today, + "company_id": self.company.id, + "currency_id": self.company_data["currency"].id, + } + ) + ], + } + ) + invoice = sheet._create_supplier_invoices() + self.assertEqual(len(invoice.invoice_line_ids), 1) + self.assertTrue(invoice.invoice_line_ids.tax_ids) + + def test_coverage_boosters(self): + sheet = self._create_own_account_sheet() + sheet.action_submit_sheet() + posted_move = self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": sheet.journal_id.id, + "line_ids": [ + Command.create( + { + "account_id": self.company_data[ + "default_account_expense" + ].id, + "debit": 10.0, + "credit": 0.0, + "name": "Posted move", + } + ), + Command.create( + { + "account_id": self.company_data[ + "default_account_payable" + ].id, + "debit": 0.0, + "credit": 10.0, + "name": "Offset", + } + ), + ], + } + ) + posted_move.action_post() + cancelled_move = self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": sheet.journal_id.id, + "line_ids": [ + Command.create( + { + "account_id": self.company_data[ + "default_account_expense" + ].id, + "debit": 5.0, + "credit": 0.0, + "name": "Cancelled move", + } + ), + Command.create( + { + "account_id": self.company_data[ + "default_account_payable" + ].id, + "debit": 0.0, + "credit": 5.0, + "name": "Offset", + } + ), + ], + } + ) + cancelled_move.action_post() + cancelled_move.button_cancel() + sheet.write( + { + "account_move_ids": [ + Command.link(posted_move.id), + Command.link(cancelled_move.id), + ] + } + ) + sheet.action_approve_expense_sheets() + self.assertFalse(posted_move.exists()) + self.assertFalse(cancelled_move.exists()) + + pay_journal = self.env["account.journal"].create( + { + "name": "Bank without methods", + "type": "bank", + "code": "BNM", + "company_id": self.company.id, + } + ) + pay_journal.outbound_payment_method_line_ids.unlink() + self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ("company_id", "=", self.company.id), + ("id", "!=", pay_journal.id), + ] + ).write({"active": False}) + payment_sheet = self._create_own_account_sheet() + payment_sheet.action_submit_sheet() + payment_sheet.action_approve_expense_sheets() + supplier_invoice = payment_sheet.account_move_ids.filtered( + lambda move: move.partner_id == self.vendor + ) + supplier_invoice.action_post() + payment_sheet._generate_supplier_payments() + self.assertTrue(pay_journal.outbound_payment_method_line_ids) + + single_line_sheet = self._create_own_account_sheet() + credit_account = self.company.hr_expense_reimbursement_credit_account_id + single_move = self.env["account.move"].create( + { + "move_type": "entry", + "line_ids": [ + Command.create( + { + "account_id": credit_account.id, + "debit": 50.0, + "credit": 0.0, + "name": "Single credit line", + } + ), + Command.create( + { + "account_id": self.company_data[ + "default_account_expense" + ].id, + "debit": 0.0, + "credit": 50.0, + "name": "Offset", + } + ), + ], + } + ) + single_move.action_post() + single_line_sheet.write({"account_move_ids": [Command.link(single_move.id)]}) + single_line_sheet._reconcile_account_lines() + credit_line = single_move.line_ids.filtered( + lambda line: line.account_id == credit_account + ) + self.assertFalse(credit_line.reconciled) diff --git a/hr_expense_vendor_bill/views/account_move_views.xml b/hr_expense_vendor_bill/views/account_move_views.xml new file mode 100644 index 000000000..460c94aad --- /dev/null +++ b/hr_expense_vendor_bill/views/account_move_views.xml @@ -0,0 +1,12 @@ + + + account.move.form.inherit.expense.sheet + account.move + + + + + + + + diff --git a/hr_expense_vendor_bill/views/hr_expense_sheet_inherit_views.xml b/hr_expense_vendor_bill/views/hr_expense_sheet_inherit_views.xml new file mode 100644 index 000000000..75d9cfc86 --- /dev/null +++ b/hr_expense_vendor_bill/views/hr_expense_sheet_inherit_views.xml @@ -0,0 +1,15 @@ + + + hr.expense.sheet.form.hide_residual + hr.expense.sheet + + + + 1 + + + + diff --git a/hr_expense_vendor_bill/views/hr_expense_views.xml b/hr_expense_vendor_bill/views/hr_expense_views.xml new file mode 100644 index 000000000..ca7f19c38 --- /dev/null +++ b/hr_expense_vendor_bill/views/hr_expense_views.xml @@ -0,0 +1,13 @@ + + + hr.expense.form.inherit.vendor + hr.expense + + + + + + + + + diff --git a/hr_expense_vendor_bill/views/res_config_settings_views.xml b/hr_expense_vendor_bill/views/res_config_settings_views.xml new file mode 100644 index 000000000..5e6280248 --- /dev/null +++ b/hr_expense_vendor_bill/views/res_config_settings_views.xml @@ -0,0 +1,30 @@ + + + hr.expense.config.settings.inherit + res.config.settings + + + +
+
+
+
+
+
+
+
+
+