diff --git a/hr_expense_payment/README.rst b/hr_expense_payment/README.rst new file mode 100644 index 000000000..927d6600d --- /dev/null +++ b/hr_expense_payment/README.rst @@ -0,0 +1,104 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================== +HR Expense Payment +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a4877247ec13add9edf6cb6f2a5251731ecb0da24f8c9ac64988a591086779d3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/hr_expense_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-19-0/hr-expense-19-0-hr_expense_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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module links each employee-paid expense to the payment(s) that +settled it, in both directions: ``hr.expense.payment_ids`` and +``account.payment.reconciled_expense_ids``. + +Both fields are computed from core's move-level link data — reconciled +payments, partials included, and payments matched by the payment +register — so nothing is stored that could go stale. Core's own +``account.payment.expense_ids`` covers company-paid expenses; these +fields cover the employee-reimbursement direction. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Both fields are read-only, searchable, and readable by employees on +their own expenses; use them in views, filters, automations or dependent +modules. + +Upgrading from 18.0: links the accounting data cannot rebuild are +carried onto the expenses' journal entries by +``migrations/19.0.1.0.0/post-migration.py``. + +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 +------- + +* Tecnativa +* Ecosoft + +Contributors +------------ + +- Saran Lim. +- Manuel Regidor +- ``APSL ``\ \_\_: + + - Antoni Marroig + +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_payment/__init__.py b/hr_expense_payment/__init__.py new file mode 100644 index 000000000..31660d6a9 --- /dev/null +++ b/hr_expense_payment/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/hr_expense_payment/__manifest__.py b/hr_expense_payment/__manifest__.py new file mode 100644 index 000000000..e00845fc3 --- /dev/null +++ b/hr_expense_payment/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# Copyright 2021 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR Expense Payment", + "version": "19.0.1.0.0", + "category": "Human Resources", + "author": "Tecnativa, Ecosoft, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/hr-expense", + "depends": ["hr_expense"], + "data": [], + "installable": True, +} diff --git a/hr_expense_payment/i18n/hr_expense_payment.pot b/hr_expense_payment/i18n/hr_expense_payment.pot new file mode 100644 index 000000000..7ddd6dbe7 --- /dev/null +++ b/hr_expense_payment/i18n/hr_expense_payment.pot @@ -0,0 +1,39 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_payment +# +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_payment +#: model:ir.model,name:hr_expense_payment.model_hr_expense_sheet +msgid "Expense Report" +msgstr "" + +#. module: hr_expense_payment +#: model:ir.model.fields,field_description:hr_expense_payment.field_account_payment__expense_sheet_ids +msgid "Expense sheet" +msgstr "" + +#. module: hr_expense_payment +#: model:ir.model,name:hr_expense_payment.model_account_payment_register +msgid "Pay" +msgstr "" + +#. module: hr_expense_payment +#: model:ir.model.fields,field_description:hr_expense_payment.field_hr_expense_sheet__payment_ids +msgid "Payment" +msgstr "" + +#. module: hr_expense_payment +#: model:ir.model,name:hr_expense_payment.model_account_payment +msgid "Payments" +msgstr "" diff --git a/hr_expense_payment/i18n/it.po b/hr_expense_payment/i18n/it.po new file mode 100644 index 000000000..a7535c5bf --- /dev/null +++ b/hr_expense_payment/i18n/it.po @@ -0,0 +1,48 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_expense_payment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-01-09 11:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: hr_expense_payment +#: model:ir.model,name:hr_expense_payment.model_hr_expense_sheet +msgid "Expense Report" +msgstr "Nota spese" + +#. module: hr_expense_payment +#: model:ir.model.fields,field_description:hr_expense_payment.field_account_payment__expense_sheet_ids +msgid "Expense sheet" +msgstr "Foglio spesa" + +#. module: hr_expense_payment +#: model:ir.model,name:hr_expense_payment.model_account_payment_register +msgid "Pay" +msgstr "Paga" + +#. module: hr_expense_payment +#: model:ir.model.fields,field_description:hr_expense_payment.field_hr_expense_sheet__payment_ids +msgid "Payment" +msgstr "Pagamento" + +#. module: hr_expense_payment +#: model:ir.model,name:hr_expense_payment.model_account_payment +msgid "Payments" +msgstr "Pagamenti" + +#~ msgid "Register Payment" +#~ msgstr "Registra pagamento" + +#~ msgid "Expense" +#~ msgstr "Spesa" diff --git a/hr_expense_payment/migrations/19.0.1.0.0/post-migration.py b/hr_expense_payment/migrations/19.0.1.0.0/post-migration.py new file mode 100644 index 000000000..0b678475c --- /dev/null +++ b/hr_expense_payment/migrations/19.0.1.0.0/post-migration.py @@ -0,0 +1,53 @@ +# Copyright 2026 Don Kendall +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo.tools.sql import table_exists + +_logger = logging.getLogger(__name__) + +LEGACY_RELATION = "payment_expense_sheet_rel" + + +def migrate(cr, version): + """Carry 18.0 rel rows whose reconciliation was since undone into core's + account_move__account_payment, via hr.expense.former_sheet_id (the legacy + pointer upgrade tools fill). Wizard-recorded links already live there and + survive on their own. Idempotent; no-op when the table is absent or the + pointer was left unfilled. + """ + if not table_exists(cr, LEGACY_RELATION): + return + cr.execute("SELECT COUNT(*) FROM hr_expense WHERE former_sheet_id IS NOT NULL") + if not cr.fetchone()[0]: + _logger.warning( + "%s exists but hr_expense.former_sheet_id is unfilled; the " + "upgrade path kept no sheet mapping, no links carried.", + LEGACY_RELATION, + ) + return + cr.execute( + """ + INSERT INTO account_move__account_payment (invoice_id, payment_id) + SELECT DISTINCT expense.account_move_id, legacy.payment_id + FROM payment_expense_sheet_rel AS legacy + JOIN hr_expense AS expense ON expense.former_sheet_id = legacy.sheet_id + WHERE expense.account_move_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM account_payment AS payment + WHERE payment.id = legacy.payment_id + ) + AND NOT EXISTS ( + SELECT 1 FROM account_move__account_payment AS existing + WHERE existing.invoice_id = expense.account_move_id + AND existing.payment_id = legacy.payment_id + ) + """ + ) + _logger.info( + "hr_expense_payment: carried %s payment link(s) from %s onto the " + "expense journal entries.", + cr.rowcount, + LEGACY_RELATION, + ) diff --git a/hr_expense_payment/models/__init__.py b/hr_expense_payment/models/__init__.py new file mode 100644 index 000000000..82e1cb11c --- /dev/null +++ b/hr_expense_payment/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import account_payment +from . import hr_expense diff --git a/hr_expense_payment/models/account_payment.py b/hr_expense_payment/models/account_payment.py new file mode 100644 index 000000000..23607918f --- /dev/null +++ b/hr_expense_payment/models/account_payment.py @@ -0,0 +1,36 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# Copyright 2021 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + reconciled_expense_ids = fields.Many2many( + comodel_name="hr.expense", + string="Reimbursed Expenses", + compute="_compute_reconciled_expense_ids", + compute_sudo=True, + search="_search_reconciled_expense_ids", + help="Employee-paid expenses whose journal entry is linked to this " + "payment, by reconciliation or by the payment register.", + ) + + @api.depends( + "invoice_ids", + "move_id.line_ids.matched_debit_ids", + "move_id.line_ids.matched_credit_ids", + ) + def _compute_reconciled_expense_ids(self): + for payment in self: + payment.reconciled_expense_ids = ( + payment.invoice_ids | payment.reconciled_bill_ids + ).expense_ids + + def _search_reconciled_expense_ids(self, operator, value): + if operator not in ("in", "="): + return NotImplemented + expenses = self.env["hr.expense"].browse(value) + return [("id", "in", expenses.payment_ids.ids)] diff --git a/hr_expense_payment/models/hr_expense.py b/hr_expense_payment/models/hr_expense.py new file mode 100644 index 000000000..9dac85764 --- /dev/null +++ b/hr_expense_payment/models/hr_expense.py @@ -0,0 +1,34 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# Copyright 2021 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + payment_ids = fields.Many2many( + comodel_name="account.payment", + string="Payments", + compute="_compute_payment_ids", + compute_sudo=True, + search="_search_payment_ids", + help="Payments linked to this expense's journal entry, whether " + "reconciled with it or matched by the payment register.", + ) + + @api.depends( + "account_move_id.line_ids.matched_debit_ids", + "account_move_id.line_ids.matched_credit_ids", + "account_move_id.matched_payment_ids", + ) + def _compute_payment_ids(self): + for expense in self: + expense.payment_ids = expense.account_move_id.reconciled_payment_ids + + def _search_payment_ids(self, operator, value): + if operator not in ("in", "="): + return NotImplemented + payments = self.env["account.payment"].browse(value) + return [("id", "in", payments.reconciled_expense_ids.ids)] diff --git a/hr_expense_payment/pyproject.toml b/hr_expense_payment/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_payment/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_payment/readme/CONTRIBUTORS.md b/hr_expense_payment/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..36333bd91 --- /dev/null +++ b/hr_expense_payment/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Saran Lim. \<\> +- Manuel Regidor \<\> +- `APSL `__: + - Antoni Marroig \<\> diff --git a/hr_expense_payment/readme/DESCRIPTION.md b/hr_expense_payment/readme/DESCRIPTION.md new file mode 100644 index 000000000..68aad210d --- /dev/null +++ b/hr_expense_payment/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module links each employee-paid expense to the payment(s) that settled +it, in both directions: `hr.expense.payment_ids` and +`account.payment.reconciled_expense_ids`. + +Both fields are computed from core's move-level link data — reconciled +payments, partials included, and payments matched by the payment register — +so nothing is stored that could go stale. Core's own +`account.payment.expense_ids` covers company-paid expenses; these fields +cover the employee-reimbursement direction. diff --git a/hr_expense_payment/readme/USAGE.md b/hr_expense_payment/readme/USAGE.md new file mode 100644 index 000000000..52ea2ea9b --- /dev/null +++ b/hr_expense_payment/readme/USAGE.md @@ -0,0 +1,5 @@ +Both fields are read-only, searchable, and readable by employees on their +own expenses; use them in views, filters, automations or dependent modules. + +Upgrading from 18.0: links the accounting data cannot rebuild are carried +onto the expenses' journal entries by `migrations/19.0.1.0.0/post-migration.py`. diff --git a/hr_expense_payment/static/description/icon.png b/hr_expense_payment/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/hr_expense_payment/static/description/icon.png differ diff --git a/hr_expense_payment/static/description/index.html b/hr_expense_payment/static/description/index.html new file mode 100644 index 000000000..e0a72c621 --- /dev/null +++ b/hr_expense_payment/static/description/index.html @@ -0,0 +1,452 @@ + + + + + +HR Expense Payment + + + +
+ + + +Odoo Community Association + +
+

HR Expense Payment

+ +

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

+

This module links each employee-paid expense to the payment(s) that +settled it, in both directions: hr.expense.payment_ids and +account.payment.reconciled_expense_ids.

+

Both fields are computed from core’s move-level link data — reconciled +payments, partials included, and payments matched by the payment +register — so nothing is stored that could go stale. Core’s own +account.payment.expense_ids covers company-paid expenses; these +fields cover the employee-reimbursement direction.

+

Table of contents

+ +
+

Usage

+

Both fields are read-only, searchable, and readable by employees on +their own expenses; use them in views, filters, automations or dependent +modules.

+

Upgrading from 18.0: links the accounting data cannot rebuild are +carried onto the expenses’ journal entries by +migrations/19.0.1.0.0/post-migration.py.

+
+
+

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

+
    +
  • Tecnativa
  • +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

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_payment/tests/__init__.py b/hr_expense_payment/tests/__init__.py new file mode 100644 index 000000000..36579e552 --- /dev/null +++ b/hr_expense_payment/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_hr_expense_payment diff --git a/hr_expense_payment/tests/test_hr_expense_payment.py b/hr_expense_payment/tests/test_hr_expense_payment.py new file mode 100644 index 000000000..4d506fc37 --- /dev/null +++ b/hr_expense_payment/tests/test_hr_expense_payment.py @@ -0,0 +1,232 @@ +# Copyright 2019 Tecnativa - Ernesto Tejeda +# Copyright 2021 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# Copyright 2024 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import importlib.util +from pathlib import Path + +from odoo.tests import Form, tagged + +from odoo.addons.hr_expense.tests.common import TestExpenseCommon + + +def _load_post_migration(): + """Import the 19.0.1.0.0 upgrade script by path (not an importable module).""" + path = Path(__file__).parents[1] / "migrations" / "19.0.1.0.0" / "post-migration.py" + spec = importlib.util.spec_from_file_location("hr_expense_payment_post_mig", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@tagged("-at_install", "post_install") +class TestHrExpensePayment(TestExpenseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Build an employee-paid expense, submit + approve + post (= receipt). + cls.expense = cls.create_expenses({"payment_mode": "own_account"}) + cls.expense.action_submit() + cls.expense.action_approve() + cls.post_expenses_with_wizard(cls.expense) + + def _get_payment_wizard(self, expenses, amount=None): + action = expenses.action_pay() + wizard_form = Form( + self.env[action["res_model"]].with_context(**action["context"]) + ) + wizard_form.journal_id = self.company_data["default_journal_bank"] + if amount is not None: + wizard_form.amount = amount + return wizard_form.save() + + def test_core_expense_ids_field_not_overridden(self): + """The module must not redefine core's account.payment.expense_ids + (related to move_id.expense_ids).""" + field = self.env["account.payment"]._fields["expense_ids"] + self.assertEqual(field.type, "one2many") + self.assertEqual(field.related, "move_id.expense_ids") + + def test_action_pay_links_payment_back_to_expense(self): + """Registering payment from the expense links both directions.""" + self.assertFalse(self.expense.payment_ids) + wizard = self._get_payment_wizard(self.expense) + wizard.action_create_payments() + self.assertEqual(len(self.expense.payment_ids), 1) + payment = self.expense.payment_ids + self.assertIn(self.expense, payment.reconciled_expense_ids) + # Core's related field stays empty for reimbursement payments. + self.assertFalse(payment.expense_ids) + + def test_partial_payment_links(self): + """Partially reconciled payments are linked too.""" + wizard = self._get_payment_wizard( + self.expense, amount=self.expense.total_amount / 2 + ) + wizard.action_create_payments() + self.assertEqual(len(self.expense.payment_ids), 1) + self.assertIn(self.expense, self.expense.payment_ids.reconciled_expense_ids) + # Pay the remainder: both payments end up linked. + wizard = self._get_payment_wizard(self.expense) + wizard.action_create_payments() + self.assertEqual(len(self.expense.payment_ids), 2) + + def test_grouped_payment_links_all_expenses(self): + """One grouped payment for several expenses back-links to all.""" + expense_2 = self.create_expenses( + {"payment_mode": "own_account", "total_amount_currency": 123.0} + ) + expense_2.action_submit() + expense_2.action_approve() + self.post_expenses_with_wizard(expense_2) + expenses = self.expense | expense_2 + action = expenses.action_pay() + wizard = ( + self.env[action["res_model"]] + .with_context(**action["context"]) + .create({"group_payment": True}) + ) + wizard.action_create_payments() + payment = self.expense.payment_ids + self.assertEqual(len(payment), 1) + self.assertEqual(expense_2.payment_ids, payment) + self.assertEqual(payment.reconciled_expense_ids, expenses) + + def test_unreconcile_keeps_wizard_link(self): + """Wizard-registered payments stay linked after unreconciling, via + core's stored matched_payment_ids (reconciled_payment_ids semantics).""" + wizard = self._get_payment_wizard(self.expense) + wizard.action_create_payments() + payment = self.expense.payment_ids + self.expense.account_move_id.line_ids.remove_move_reconcile() + self.assertEqual(self.expense.payment_ids, payment) + self.assertIn(self.expense, payment.reconciled_expense_ids) + + def test_manual_reconcile_link_follows_reconciliation(self): + """A manually created and reconciled payment links through the + reconciliation only, so unreconciling dissolves it.""" + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.expense.employee_id.work_contact_id.id, + "amount": self.expense.total_amount, + "journal_id": self.company_data["default_journal_bank"].id, + # An outstanding account so the payment generates a move. + "payment_method_line_id": self.outbound_payment_method_line.id, + } + ) + payment.action_post() + self.assertTrue(payment.move_id) + self.assertFalse(self.expense.payment_ids) + lines = ( + payment.move_id.line_ids + self.expense.account_move_id.line_ids + ).filtered( + lambda line: line.account_id.account_type == "liability_payable" + and not line.reconciled + ) + lines.reconcile() + self.assertEqual(self.expense.payment_ids, payment) + self.assertIn(self.expense, payment.reconciled_expense_ids) + self.expense.account_move_id.line_ids.remove_move_reconcile() + self.assertFalse(self.expense.payment_ids) + self.assertFalse(payment.reconciled_expense_ids) + + def test_migration_carries_legacy_sheet_links(self): + """The upgrade script carries a legacy link whose reconciliation no + longer exists onto the expense's entry, idempotently.""" + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.expense.employee_id.work_contact_id.id, + "amount": self.expense.total_amount, + "journal_id": self.company_data["default_journal_bank"].id, + "payment_method_line_id": self.outbound_payment_method_line.id, + } + ) + payment.action_post() + # Nothing ties the two together today. + self.assertFalse(self.expense.payment_ids) + legacy_sheet_id = 4242 + self.expense.former_sheet_id = legacy_sheet_id + self.expense.flush_recordset() + self.env.cr.execute( + """ + CREATE TABLE payment_expense_sheet_rel ( + payment_id integer NOT NULL, + sheet_id integer NOT NULL, + PRIMARY KEY (payment_id, sheet_id) + ); + INSERT INTO payment_expense_sheet_rel VALUES (%s, %s); + """, + (payment.id, legacy_sheet_id), + ) + post_migration = _load_post_migration() + post_migration.migrate(self.env.cr, "19.0.1.0.0") + self.env.invalidate_all() + self.assertEqual(self.expense.payment_ids, payment) + self.assertIn(self.expense, payment.reconciled_expense_ids) + # Running it again does not duplicate the link. + post_migration.migrate(self.env.cr, "19.0.1.0.0") + self.env.invalidate_all() + self.assertEqual(self.expense.payment_ids, payment) + + def test_migration_is_a_noop_without_the_legacy_table(self): + """On a fresh 19.0 install the script does nothing and does not fail.""" + post_migration = _load_post_migration() + post_migration.migrate(self.env.cr, "19.0.1.0.0") + self.assertFalse(self.expense.payment_ids) + + def test_migration_noops_when_former_sheet_id_unfilled(self): + """Unfilled legacy pointer: warn and carry nothing.""" + self.env.cr.execute( + """ + CREATE TABLE payment_expense_sheet_rel ( + payment_id integer NOT NULL, + sheet_id integer NOT NULL + ); + INSERT INTO payment_expense_sheet_rel VALUES (1, 4242); + """ + ) + post_migration = _load_post_migration() + with self.assertLogs(level="WARNING") as capture: + post_migration.migrate(self.env.cr, "19.0.1.0.0") + self.assertTrue(any("unfilled" in line for line in capture.output)) + self.assertFalse(self.expense.payment_ids) + + def test_search_payment_ids(self): + """Both computed fields are searchable.""" + wizard = self._get_payment_wizard(self.expense) + wizard.action_create_payments() + payment = self.expense.payment_ids + found_expenses = self.env["hr.expense"].search( + [("payment_ids", "in", payment.ids)] + ) + self.assertEqual(found_expenses, self.expense) + found_payments = self.env["account.payment"].search( + [("reconciled_expense_ids", "in", self.expense.ids)] + ) + self.assertEqual(found_payments, payment) + + def test_payment_ids_readable_by_employee(self): + """Employees without accounting access can read the computed link.""" + wizard = self._get_payment_wizard(self.expense) + wizard.action_create_payments() + expense = self.expense.with_user(self.expense_user_employee) + self.assertEqual(len(expense.payment_ids), 1) + + def test_company_paid_expense_not_linked(self): + """Company-paid expenses stay core-only; module fields stay empty.""" + expense = self.create_expenses( + {"payment_mode": "company_account", "total_amount_currency": 200.0} + ) + expense.action_submit() + expense.action_approve() + expense.action_post() + self.assertFalse(expense.payment_ids) + payment = expense.account_move_id.origin_payment_id + if payment: + self.assertFalse(payment.reconciled_expense_ids) + self.assertIn(expense, payment.expense_ids)