diff --git a/hr_expense_move_sync/README.rst b/hr_expense_move_sync/README.rst new file mode 100644 index 000000000..6ab5cedfe --- /dev/null +++ b/hr_expense_move_sync/README.rst @@ -0,0 +1,101 @@ +==================== +HR Expense Move Sync +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:placeholder + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_move_sync + :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_move_sync + :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 adds automatic synchronization between approved expense +reports and their linked draft journal entries. When an expense is +edited after approval, the linked vendor bills or journal entries are +regenerated to reflect the changes. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +By default, manual sync is enabled. After approving an expense report, +you can click the **Sync Accounting Entries** button on the expense +sheet to regenerate the draft journal entries with the latest expense +data. + +To enable automatic sync, go to **Settings > Expenses** and tick +*Auto-sync expense moves*. When enabled, saving any edit on an approved +expense automatically regenerates the linked draft journal entries. + +In both modes, posted or paid journal entries are never affected. + +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 +------- + +* Ecosoft + +Contributors +------------ + +- Saran Lim. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current `maintainer `__: + +|maintainer-Saran440| + +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_move_sync/__init__.py b/hr_expense_move_sync/__init__.py new file mode 100644 index 000000000..69f7babdf --- /dev/null +++ b/hr_expense_move_sync/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/hr_expense_move_sync/__manifest__.py b/hr_expense_move_sync/__manifest__.py new file mode 100644 index 000000000..70a213e0c --- /dev/null +++ b/hr_expense_move_sync/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "HR Expense Move Sync", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "category": "Human Resources", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/hr-expense", + "depends": ["hr_expense"], + "data": [ + "views/hr_expense_sheet_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, + "maintainers": ["Saran440"], +} diff --git a/hr_expense_move_sync/models/__init__.py b/hr_expense_move_sync/models/__init__.py new file mode 100644 index 000000000..08f680f6f --- /dev/null +++ b/hr_expense_move_sync/models/__init__.py @@ -0,0 +1,7 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import res_company +from . import res_config_settings +from . import account_move +from . import hr_expense_sheet +from . import hr_expense diff --git a/hr_expense_move_sync/models/account_move.py b/hr_expense_move_sync/models/account_move.py new file mode 100644 index 000000000..8c2ed90c8 --- /dev/null +++ b/hr_expense_move_sync/models/account_move.py @@ -0,0 +1,18 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def button_draft(self): + return super( + AccountMove, self.with_context(skip_move_refresh=True) + ).button_draft() + + def button_cancel(self): + return super( + AccountMove, self.with_context(skip_move_refresh=True) + ).button_cancel() diff --git a/hr_expense_move_sync/models/hr_expense.py b/hr_expense_move_sync/models/hr_expense.py new file mode 100644 index 000000000..607a64a55 --- /dev/null +++ b/hr_expense_move_sync/models/hr_expense.py @@ -0,0 +1,29 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + def _is_auto_refresh(self): + return self.env.company.auto_refresh_account_moves + + def _domain_refresh(self, sheet): + return sheet and sheet.state == "approve" and sheet.account_move_ids + + def write(self, vals): + sheets_to_refresh = self.env["hr.expense.sheet"] + # skip_move_refresh is set by account.move button_draft/button_cancel: + # when a move is reset/cancelled, the writes triggered on its expense + # lines are not user edits and must not regenerate the draft move. + if self._is_auto_refresh() and not self.env.context.get("skip_move_refresh"): + for expense in self: + sheet = expense.sheet_id + if expense._domain_refresh(sheet): + sheets_to_refresh |= sheet + res = super().write(vals) + if sheets_to_refresh: + sheets_to_refresh._refresh_account_moves() + return res diff --git a/hr_expense_move_sync/models/hr_expense_sheet.py b/hr_expense_move_sync/models/hr_expense_sheet.py new file mode 100644 index 000000000..f0b1cf063 --- /dev/null +++ b/hr_expense_move_sync/models/hr_expense_sheet.py @@ -0,0 +1,39 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class HrExpenseSheet(models.Model): + _inherit = "hr.expense.sheet" + + def action_sync_account_moves(self): + """manually regenerate draft account moves + to sync with the current expense line data.""" + return self._refresh_account_moves() + + def _refresh_account_moves(self): + """Delete draft account moves (and linked payments) then recreate + them to sync with the current expense line data. + + This is called after expense fields that affect the move are + modified while the sheet is in the 'approve' state. + """ + sheets_to_recreate = self.env["hr.expense.sheet"] + for sheet in self: + draft_moves = sheet.sudo().account_move_ids.filtered( + lambda m: m.state == "draft" + ) + if not draft_moves: + continue + + # Delete linked payments first (company_account flow) + payments = draft_moves.origin_payment_id + if payments: + payments.sudo().unlink() + else: + draft_moves.sudo().unlink() + sheets_to_recreate |= sheet + if sheets_to_recreate: + return sheets_to_recreate._do_create_moves() + return False diff --git a/hr_expense_move_sync/models/res_company.py b/hr_expense_move_sync/models/res_company.py new file mode 100644 index 000000000..96758fea1 --- /dev/null +++ b/hr_expense_move_sync/models/res_company.py @@ -0,0 +1,15 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + auto_refresh_account_moves = fields.Boolean( + string="Auto Sync Expense Moves", + default=False, + help="When enabled, editing an approved expense automatically " + "regenerates the linked draft journal entries to stay in sync.", + ) diff --git a/hr_expense_move_sync/models/res_config_settings.py b/hr_expense_move_sync/models/res_config_settings.py new file mode 100644 index 000000000..b99b78b27 --- /dev/null +++ b/hr_expense_move_sync/models/res_config_settings.py @@ -0,0 +1,13 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + auto_refresh_account_moves = fields.Boolean( + related="company_id.auto_refresh_account_moves", + readonly=False, + ) diff --git a/hr_expense_move_sync/pyproject.toml b/hr_expense_move_sync/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_move_sync/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_move_sync/readme/CONFIGURE.md b/hr_expense_move_sync/readme/CONFIGURE.md new file mode 100644 index 000000000..3f61abaae --- /dev/null +++ b/hr_expense_move_sync/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +By default, manual sync is enabled. After approving an expense report, +you can click the **Sync Accounting Entries** button on the expense +sheet to regenerate the draft journal entries with the latest expense +data. + +To enable automatic sync, go to **Settings > Expenses** and tick +*Auto-sync expense moves*. When enabled, saving any edit on an approved +expense automatically regenerates the linked draft journal entries. + +In both modes, posted or paid journal entries are never affected. diff --git a/hr_expense_move_sync/readme/CONTRIBUTORS.md b/hr_expense_move_sync/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..8d0d33151 --- /dev/null +++ b/hr_expense_move_sync/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Saran Lim. \<\> diff --git a/hr_expense_move_sync/readme/DESCRIPTION.md b/hr_expense_move_sync/readme/DESCRIPTION.md new file mode 100644 index 000000000..e382cf037 --- /dev/null +++ b/hr_expense_move_sync/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module adds automatic synchronization between approved expense +reports and their linked draft journal entries. When an expense is +edited after approval, the linked vendor bills or journal entries are +regenerated to reflect the changes. diff --git a/hr_expense_move_sync/static/description/index.html b/hr_expense_move_sync/static/description/index.html new file mode 100644 index 000000000..b6ad2950e --- /dev/null +++ b/hr_expense_move_sync/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +HR Expense Move Sync + + + +
+

HR Expense Move Sync

+ + +

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

+

This module adds automatic synchronization between approved expense +reports and their linked draft journal entries. When an expense is +edited after approval, the linked vendor bills or journal entries are +regenerated to reflect the changes.

+

Table of contents

+ +
+

Configuration

+

By default, manual sync is enabled. After approving an expense report, +you can click the Sync Accounting Entries button on the expense +sheet to regenerate the draft journal entries with the latest expense +data.

+

To enable automatic sync, go to Settings > Expenses and tick +Auto-sync expense moves. When enabled, saving any edit on an approved +expense automatically regenerates the linked draft journal entries.

+

In both modes, posted or paid journal entries are never affected.

+
+
+

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

+
    +
  • 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.

+

Current maintainer:

+

Saran440

+

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_move_sync/tests/__init__.py b/hr_expense_move_sync/tests/__init__.py new file mode 100644 index 000000000..92bf693f9 --- /dev/null +++ b/hr_expense_move_sync/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_hr_expense_move_sync diff --git a/hr_expense_move_sync/tests/test_hr_expense_move_sync.py b/hr_expense_move_sync/tests/test_hr_expense_move_sync.py new file mode 100644 index 000000000..07e83fb71 --- /dev/null +++ b/hr_expense_move_sync/tests/test_hr_expense_move_sync.py @@ -0,0 +1,209 @@ +# Copyright 2026 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestHrExpenseMoveSync(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.expense_model = cls.env["hr.expense"] + cls.product = cls.env["product.product"].create( + {"name": "Test Expense Product", "type": "service"} + ) + cls.employee = cls.env["hr.employee"].create({"name": "Test Employee"}) + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 10%", + "amount": 10.0, + "amount_type": "percent", + } + ) + cls.expense_journal = cls.env["account.journal"].create( + { + "name": "Test Expense Journal", + "type": "purchase", + "code": "TEXJ", + } + ) + # Company-account setup + cls.outstanding_account = cls.env["account.account"].create( + { + "code": "610010", + "name": "Expense Outstanding Account", + "account_type": "asset_current", + "reconcile": True, + } + ) + cls.payment_method_line = cls.env["account.payment.method.line"].search( + [], limit=1 + ) + + def _enable_auto_refresh(self): + self.env.company.auto_refresh_account_moves = True + + def _ensure_draft_move(self, sheet): + """Return a draft account.move for the approved sheet. + + Without hr_expense_invoice, approving the sheet already creates a + draft move. With it, own_account sheets get no move at approval, so + we post then reset to draft to reach the same state. + """ + move = sheet.account_move_ids + if move and move.state == "draft": + return move + sheet.action_sheet_move_post() + move = sheet.account_move_ids + move.button_draft() + return move + + def _create_expense( + self, name, total_amount, tax_ids=None, payment_mode="own_account" + ): + """Create, submit, and approve an expense. Returns the sheet.""" + expense = self.expense_model.create( + { + "name": name, + "employee_id": self.employee.id, + "product_id": self.product.id, + "total_amount": total_amount, + "payment_mode": payment_mode, + "tax_ids": [Command.set(tax_ids)] if tax_ids else False, + } + ) + expense.action_submit_expenses() + sheet = expense.sheet_id + sheet.journal_id = self.expense_journal + if payment_mode == "company_account": + sheet.payment_method_line_id = self.payment_method_line + self.env.company.expense_outstanding_account_id = self.outstanding_account + sheet.action_submit_sheet() + sheet.action_approve_expense_sheets() + return sheet + + def test_01_manual_button_sync(self): + """Default manual mode: edit does not sync, button click does.""" + # Default = manual (auto_refresh=False), no auto-refresh + sheet = self._create_expense("Manual Sync", 200.0, [self.tax.id]) + move = self._ensure_draft_move(sheet) + self.assertTrue(move) + self.assertTrue(move.line_ids.filtered("tax_line_id")) + self.assertEqual(move.amount_total, 200.0) + + # Edit, should NOT auto-refresh + expense = sheet.expense_line_ids + expense.write({"tax_ids": False, "total_amount": 500.0}) + + # Move unchanged (manual mode) + self.assertEqual(sheet.account_move_ids, move) + self.assertTrue(move.line_ids.filtered("tax_line_id")) + self.assertEqual(move.amount_total, 200.0) + + # Click the button + sheet.action_sync_account_moves() + + # Now regenerated + new_move = sheet.account_move_ids + self.assertEqual(len(new_move), 1) + self.assertNotEqual(new_move, move) + self.assertEqual(new_move.amount_total, 500.0) + self.assertFalse(new_move.line_ids.filtered("tax_line_id")) + + def test_02_auto_sync(self): + """Auto mode: edit auto-regenerates, same result as button.""" + self._enable_auto_refresh() + sheet = self._create_expense("Auto Sync", 200.0, [self.tax.id]) + move = self._ensure_draft_move(sheet) + self.assertTrue(move) + self.assertTrue(move.line_ids.filtered("tax_line_id")) + self.assertEqual(move.amount_total, 200.0) + + # Edit, should auto-refresh + expense = sheet.expense_line_ids + expense.write({"tax_ids": False, "total_amount": 500.0}) + + # Move regenerated automatically + new_move = sheet.account_move_ids + self.assertEqual(len(new_move), 1) + self.assertNotEqual(new_move, move) + self.assertEqual(new_move.amount_total, 500.0) + self.assertFalse(new_move.line_ids.filtered("tax_line_id")) + + def test_03_posted_move_not_synced(self): + """Posted moves should not be regenerated.""" + self._enable_auto_refresh() + sheet = self._create_expense("Posted Move", 100.0, [self.tax.id]) + move = self._ensure_draft_move(sheet) + self.assertTrue(move) + + # Post the move + sheet.action_sheet_move_post() + self.assertNotEqual(sheet.state, "approve") + self.assertEqual(move.state, "posted") + + # Edit, should NOT regenerate (sheet no longer in approve state) + expense = sheet.expense_line_ids + expense.write({"total_amount": 999.0}) + + # Move unchanged, still the same posted move + self.assertEqual(sheet.account_move_ids, move) + self.assertEqual(move.state, "posted") + self.assertEqual(move.amount_total, 100.0) + + def test_04_company_account_sync(self): + """Company-paid (company_account): sync handles payments correctly.""" + self._enable_auto_refresh() + sheet = self._create_expense( + "Company Paid", 300.0, payment_mode="company_account" + ) + moves = self._ensure_draft_move(sheet) + self.assertTrue(moves) + self.assertEqual(moves.state, "draft") + payment = moves.origin_payment_id + self.assertTrue(payment) + self.assertEqual(payment.amount, 300.0) + + # Edit, auto-refresh deletes old payment+move and recreates + expense = sheet.expense_line_ids + expense.write({"total_amount": 750.0}) + + # Old move deleted, new one created + new_moves = sheet.account_move_ids + self.assertEqual(len(new_moves), 1) + self.assertNotEqual(new_moves, moves) + self.assertEqual(new_moves.state, "draft") + + # Old payment deleted, new payment created with new amount + new_payment = new_moves.origin_payment_id + self.assertTrue(new_payment) + self.assertNotEqual(new_payment, payment) + self.assertEqual(new_payment.amount, 750.0) + + def test_05_reset_to_draft_no_refresh(self): + """Resetting a posted expense move to draft must not refresh the move. + + When a posted move is reset to draft, the linked sheet recomputes its + state back to 'approve' (all moves draft). Without protection, the + auto-refresh would unlink the very move being reset, raising + "Record does not exist or has been deleted". + """ + self._enable_auto_refresh() + sheet = self._create_expense("Reset Draft", 100.0, [self.tax.id]) + move = self._ensure_draft_move(sheet) + self.assertEqual(sheet.state, "approve") + self.assertEqual(move.state, "draft") + + # Post the move, then reset it back to draft. + sheet.action_sheet_move_post() + self.assertEqual(move.state, "posted") + + move.button_draft() + + # The same move must still exist, in draft, and the sheet back to approve. + self.assertTrue(move.exists()) + self.assertEqual(move.state, "draft") + self.assertEqual(sheet.account_move_ids, move) + self.assertEqual(sheet.state, "approve") diff --git a/hr_expense_move_sync/views/hr_expense_sheet_views.xml b/hr_expense_move_sync/views/hr_expense_sheet_views.xml new file mode 100644 index 000000000..4c0b38683 --- /dev/null +++ b/hr_expense_move_sync/views/hr_expense_sheet_views.xml @@ -0,0 +1,19 @@ + + + hr.expense.sheet.form.sync + hr.expense.sheet + + + +