diff --git a/payroll_account/README.rst b/payroll_account/README.rst index da1c75591..ece201dea 100644 --- a/payroll_account/README.rst +++ b/payroll_account/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================== Payroll Accounting ================== @@ -17,7 +13,7 @@ Payroll Accounting .. |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-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpayroll-lightgray.png?logo=github @@ -46,9 +42,57 @@ like all other OCA modules. Configuration ============= -# Go to *Payroll > Configuration > Salary Rules* # Select rule that you -want in accounting # Go to *Accounting Tab* and select Debit, Credit -Account +Each salary rule carries its own accounting configuration, under +*Payroll > Configuration > Salary Rules*, tab *Accounting*: + +- **Debit Account** and **Credit Account**. A rule with neither + generates no journal item at all; a rule with only one generates a + single item, and the difference is balanced on the journal's default + account (see below). Both accounts are **company-dependent**: in a + multi-company database, configure them once per company. +- **Analytic Account**, when analytic accounting is enabled. The + contract's analytic account takes precedence over the rule's. +- **Tax** and **Base**, to let the generated items carry the tax, its + repartition line and its tax grids. + +The journal the entry lands in is taken from, in order of precedence: + +1. the **payslip's own journal**, which can be changed on the payslip; +2. the **batch's journal** (*Salary Journal* on the payslip batch), + which is what the payslips of a batch are created with; +3. the **contract's journal** (*Accounting* group of the contract form). + +Give that journal a **default account** (*Accounting > Configuration > +Journals*): it is the account the payslip's rounding/adjustment item is +booked on whenever the rules alone do not balance. Without it, +confirming such a payslip raises an error. + +Usage +===== + +Confirming a payslip generates **one** accounting entry, posted in the +payslip's journal, and links it to the payslip -- the *Journal Entry* +button on the payslip form opens it. Every salary rule with a debit +and/or a credit account contributes an item; the entry is dated with the +payslip's *Date Account*, falling back to the end of the period. + +Partners are set only on the items whose account tracks an external +relationship: the employee on receivable and current-liability accounts, +the contribution register's partner on payable ones. Expense and income +items carry no partner. + +Confirming an already-confirmed payslip does nothing: the payslip keeps +the entry it already has, instead of booking every amount twice. + +**Cancelling** a payslip does not delete its entry. A posted entry is +reversed, and both the entry and its reversal stay attached to the +payslip for the audit trail; an entry that was never posted is simply +dropped. Confirming the payslip again generates a new entry. A payslip +that still carries a posted entry cannot be deleted -- cancel it first. + +A **refund** (*Refund* on a confirmed payslip) is an ordinary payslip +with inverted amounts, so it generates its own entry, with debits and +credits the other way round. Bug Tracker =========== @@ -74,6 +118,7 @@ Contributors - Odoo SA - Saran Lim. - Daniel Reis +- Nimarosa Maintainers ----------- diff --git a/payroll_account/__manifest__.py b/payroll_account/__manifest__.py index df97ac455..42a282b29 100644 --- a/payroll_account/__manifest__.py +++ b/payroll_account/__manifest__.py @@ -1,7 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { "name": "Payroll Accounting", - "version": "18.0.1.0.6", + "version": "18.0.1.1.0", "category": "Payroll", "website": "https://github.com/OCA/payroll", "license": "LGPL-3", diff --git a/payroll_account/models/hr_contract.py b/payroll_account/models/hr_contract.py index 794cba8ec..9ac654cf2 100644 --- a/payroll_account/models/hr_contract.py +++ b/payroll_account/models/hr_contract.py @@ -6,9 +6,13 @@ class HrContract(models.Model): _inherit = "hr.contract" - _description = "Employee Contract" analytic_account_id = fields.Many2one( "account.analytic.account", "Analytic Account" ) - journal_id = fields.Many2one("account.journal", "Salary Journal") + journal_id = fields.Many2one( + "account.journal", + "Salary Journal", + check_company=True, + domain="[('company_id', '=', company_id)]", + ) diff --git a/payroll_account/models/hr_payslip.py b/payroll_account/models/hr_payslip.py index 4c6bdffb3..9c71dafb3 100644 --- a/payroll_account/models/hr_payslip.py +++ b/payroll_account/models/hr_payslip.py @@ -2,7 +2,7 @@ import logging -from odoo import _, api, fields, models +from odoo import Command, _, api, fields, models from odoo.exceptions import UserError logger = logging.getLogger(__name__) @@ -11,7 +11,16 @@ class HrPayslip(models.Model): _inherit = "hr.payslip" - journal_id = fields.Many2one(related="contract_id.journal_id", store=True) + journal_id = fields.Many2one( + "account.journal", + "Salary Journal", + compute="_compute_journal_id", + store=True, + readonly=False, + precompute=True, + check_company=True, + domain="[('company_id', '=', company_id)]", + ) date = fields.Date( "Date Account", help="Keep empty to use the period of the validation(Payslip) date.", @@ -19,64 +28,141 @@ class HrPayslip(models.Model): move_id = fields.Many2one( "account.move", "Accounting Entry", readonly=True, copy=False ) + cancelled_move_ids = fields.Many2many( + "account.move", + "hr_payslip_cancelled_move_rel", + "payslip_id", + "move_id", + string="Cancelled Accounting Entries", + readonly=True, + copy=False, + help="Accounting entries generated by this payslip and reversed when " + "it was cancelled, kept for the audit trail.", + ) + + @api.depends("payslip_run_id", "contract_id") + def _compute_journal_id(self): + """Propose a journal for the payslip, once. + + ``journal_id`` used to be a stored *related* field on the contract. + Being related, it was readonly -- the form shows it as required, but + nothing could be typed into it -- and it was recomputed from the + contract, so a value chosen for one payslip (by the batch wizard's + ``default_journal_id``, or by hand) was thrown away, and editing the + contract's journal silently rewrote the journal of payslips that were + already confirmed. - @api.onchange("contract_id") - def onchange_contract(self): - res = super().onchange_contract() - self.journal_id = ( - self.contract_id.journal_id.id - or ( - not self.contract_id - and self.default_get(["journal_id"]).get("journal_id") + It is a plain computed field now: it depends on the contract and the + batch themselves, not on their journal, so it proposes a value when + either changes and never overwrites the payslip afterwards. The + batch's journal wins over the contract's -- that is what the batch + wizard means when it passes ``default_journal_id``. + """ + for payslip in self: + payslip.journal_id = ( + payslip.payslip_run_id.journal_id + or payslip.contract_id.journal_id + or payslip.journal_id ) - or self.journal_id - ) - return res def action_payslip_cancel(self): - for payslip in self: - if not payslip.move_id.journal_id.restrict_mode_hash_table: - payslip.move_id.with_context(force_delete=True).button_cancel() - payslip.move_id.with_context(force_delete=True).unlink() - else: - payslip.move_id._reverse_moves() - payslip.move_id = False - return super().action_payslip_cancel() + # Cancel the payslip FIRST: ``super()`` still refuses the cancellation + # in several cases (a payslip that is done in a database that does not + # allow cancelling one, a refunded payslip whose refund is not + # cancelled yet), and touching the accounting entry before that would + # undo an entry whose payslip stays confirmed. + res = super().action_payslip_cancel() + for payslip in self.filtered("move_id"): + payslip._cancel_accounting_entry() + return res + + def _cancel_accounting_entry(self): + """Undo the accounting entry of a cancelled payslip. + + A posted entry is never deleted: deleting it destroys the audit trail + and leaves a hole in the journal's sequence. It is reversed instead, + and the payslip keeps a reference to it in ``cancelled_move_ids`` so + that confirming the payslip again generates a new entry rather than + reusing the reversed one. + """ + self.ensure_one() + move = self.move_id + if move.state == "draft": + # Nothing ever reached the books, so there is nothing to reverse. + move.unlink() + return move.browse() + reversal_date = max(move.date, fields.Date.context_today(self)) + reversal = move._reverse_moves([{"date": reversal_date}], cancel=True) + self.write( + { + "move_id": False, + "cancelled_move_ids": [ + Command.link(move.id), + Command.link(reversal.id), + ], + } + ) + return reversal + + def unlink(self): + if self.filtered(lambda payslip: payslip.move_id.state == "posted"): + raise UserError( + _( + "You cannot delete a payslip linked to a posted accounting " + "entry. Cancel the payslip first, so that its entry is " + "reversed." + ) + ) + return super().unlink() def action_payslip_done(self): res = super().action_payslip_done() for slip in self: + if slip.move_id: + # The payslip was already confirmed once. Generating a second + # entry would double every amount in the books. + logger.info( + "Payslip %s already has the accounting entry %s; " + "no new entry generated", + slip.number, + slip.move_id.name, + ) + continue line_ids = [] debit_sum = 0.0 credit_sum = 0.0 date = slip.date or slip.date_to - currency = ( - slip.company_id.currency_id or slip.journal_id.company_id.currency_id - ) + company = slip._get_accounting_company() + currency = company.currency_id or slip.journal_id.company_id.currency_id name = _("Payslip of %s") % (slip.employee_id.name) move_dict = { "narration": name, "ref": slip.number, "journal_id": slip.journal_id.id, + "company_id": company.id, "date": date, } for line in slip.line_ids: amount = currency.round(slip.credit_note and -line.total or line.total) if currency.is_zero(amount): continue - debit_account_id = line.salary_rule_id.account_debit.id - credit_account_id = line.salary_rule_id.account_credit.id + # ``account_debit``/``account_credit`` are company-dependent, so + # they must be read in the payslip's company and not in whatever + # company the user happens to be working in. + salary_rule = line.salary_rule_id.with_company(company) + debit_account_id = salary_rule.account_debit.id + credit_account_id = salary_rule.account_credit.id move_line_analytic_ids = {} if slip.contract_id.analytic_account_id: move_line_analytic_ids.update( {line.slip_id.contract_id.analytic_account_id.id: 100} ) - elif line.salary_rule_id.analytic_account_id: + elif salary_rule.analytic_account_id: move_line_analytic_ids.update( - {line.salary_rule_id.analytic_account_id.id: 100} + {salary_rule.analytic_account_id.id: 100} ) if debit_account_id: @@ -102,8 +188,9 @@ def action_payslip_done(self): if not acc_id: raise UserError( _( - 'The Expense Journal "%s" has not properly ' - "configured the Credit Account!" + 'The salary journal "%s" needs a default account: ' + "the payslip debits more than it credits, and the " + "difference is balanced on that account." ) % (slip.journal_id.name) ) @@ -117,8 +204,9 @@ def action_payslip_done(self): if not acc_id: raise UserError( _( - 'The Expense Journal "%s" has not properly ' - "configured the Debit Account!" + 'The salary journal "%s" needs a default account: ' + "the payslip credits more than it debits, and the " + "difference is balanced on that account." ) % (slip.journal_id.name) ) @@ -129,18 +217,37 @@ def action_payslip_done(self): if len(line_ids) > 0: move_dict["line_ids"] = line_ids - move = self.env["account.move"].create(move_dict) + move = self.env["account.move"].with_company(company).create(move_dict) slip.write({"move_id": move.id, "date": date}) move.action_post() else: logger.info( - f"Payslip {slip.number} did not generate any account move lines" + "Payslip %s did not generate any account move lines", + slip.number, ) return res + def _get_accounting_company(self): + """Company the accounting entry of this payslip belongs to.""" + self.ensure_one() + return self.company_id or self.journal_id.company_id or self.env.company + + def action_open_accounting_entry(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Journal Entry"), + "res_model": "account.move", + "view_mode": "form", + "res_id": self.move_id.id, + } + def _prepare_debit_line( self, line, amount, date, debit_account_id, move_line_analytic_ids ): + salary_rule = line.salary_rule_id.with_company( + line.slip_id._get_accounting_company() + ) tax_ids, tax_tag_ids, tax_repartition_line_id = self._get_tax_details(line) return { "name": line.name, @@ -151,7 +258,7 @@ def _prepare_debit_line( "debit": amount > 0.0 and amount or 0.0, "credit": amount < 0.0 and -amount or 0.0, "analytic_distribution": move_line_analytic_ids, - "tax_line_id": line.salary_rule_id.account_tax_id.id, + "tax_line_id": salary_rule.account_tax_id.id, "tax_ids": tax_ids, "tax_repartition_line_id": tax_repartition_line_id, "tax_tag_ids": tax_tag_ids, @@ -160,6 +267,9 @@ def _prepare_debit_line( def _prepare_credit_line( self, line, amount, date, credit_account_id, move_line_analytic_ids ): + salary_rule = line.salary_rule_id.with_company( + line.slip_id._get_accounting_company() + ) tax_ids, tax_tag_ids, tax_repartition_line_id = self._get_tax_details(line) return { "name": line.name, @@ -170,7 +280,7 @@ def _prepare_credit_line( "debit": amount < 0.0 and -amount or 0.0, "credit": amount > 0.0 and amount or 0.0, "analytic_distribution": move_line_analytic_ids, - "tax_line_id": line.salary_rule_id.account_tax_id.id, + "tax_line_id": salary_rule.account_tax_id.id, "tax_ids": tax_ids, "tax_repartition_line_id": tax_repartition_line_id, "tax_tag_ids": tax_tag_ids, @@ -205,48 +315,44 @@ def _prepare_adjust_debit_line( } def _get_tax_details(self, line): + company = line.slip_id._get_accounting_company() + salary_rule = line.salary_rule_id.with_company(company) + TaxRepLine = self.env["account.tax.repartition.line"] + company_domain = TaxRepLine._check_company_domain(company) + tax_ids = False - tax_tag_ids = [] - salary_rule = line.salary_rule_id + tax_tag_ids = self.env["account.account.tag"] if salary_rule.tax_line_ids: - account_tax_ids = [ - salary_rule_id.account_tax_id.id - for salary_rule_id in salary_rule.tax_line_ids - ] - tax_ids = [(4, account_tax_id, 0) for account_tax_id in account_tax_ids] - TaxRepLine = self.env["account.tax.repartition.line"] - tax_tag_ids = TaxRepLine.search( - [ - ("tax_id", "in", account_tax_ids), + account_taxes = salary_rule.tax_line_ids.account_tax_id + tax_ids = [Command.set(account_taxes.ids)] + tax_tag_ids |= TaxRepLine.search( + company_domain + + [ + ("tax_id", "in", account_taxes.ids), ("repartition_type", "=", "base"), ] ).tag_ids tax_repartition_line_id = False if salary_rule.account_tax_id: - TaxRepLine = self.env["account.tax.repartition.line"] - tax_repartition_line_id = TaxRepLine.search( - [ - ("document_type", "=", "invoice"), - ("tax_id", "=", salary_rule.account_tax_id.id), - ( - "account_id", - "=", - salary_rule.account_debit.id or salary_rule.account_credit.id, - ), - ] - ).id - tax_tag_ids += TaxRepLine.search( - [ - ("document_type", "=", "invoice"), - ("tax_id", "=", salary_rule.account_tax_id.id), - ("repartition_type", "=", "tax"), - ( - "account_id", - "=", - salary_rule.account_debit.id or salary_rule.account_credit.id, - ), - ] + tax_domain = company_domain + [ + ("document_type", "=", "invoice"), + ("tax_id", "=", salary_rule.account_tax_id.id), + ( + "account_id", + "=", + salary_rule.account_debit.id or salary_rule.account_credit.id, + ), + ] + # ``limit=1``: several repartition lines can share the same account, + # and reading ``.id`` off a multi-record recordset raises. + tax_repartition_line_id = TaxRepLine.search(tax_domain, limit=1).id + tax_tag_ids |= TaxRepLine.search( + tax_domain + [("repartition_type", "=", "tax")] ).tag_ids - return tax_ids, tax_tag_ids or False, tax_repartition_line_id + return ( + tax_ids, + [Command.set(tax_tag_ids.ids)] if tax_tag_ids else False, + tax_repartition_line_id, + ) diff --git a/payroll_account/models/hr_payslip_run.py b/payroll_account/models/hr_payslip_run.py index 967833945..63434bf9d 100644 --- a/payroll_account/models/hr_payslip_run.py +++ b/payroll_account/models/hr_payslip_run.py @@ -11,7 +11,10 @@ class HrPayslipRun(models.Model): "account.journal", "Salary Journal", required=True, + check_company=True, + domain="[('company_id', '=', company_id)]", default=lambda self: self.env["account.journal"].search( - [("type", "=", "general")], limit=1 + [("type", "=", "general"), ("company_id", "=", self.env.company.id)], + limit=1, ), ) diff --git a/payroll_account/models/hr_salary_rule.py b/payroll_account/models/hr_salary_rule.py index 404989c36..1a1511d59 100644 --- a/payroll_account/models/hr_salary_rule.py +++ b/payroll_account/models/hr_salary_rule.py @@ -14,12 +14,14 @@ class HrSalaryRule(models.Model): "Debit Account", domain=[("deprecated", "=", False)], company_dependent=True, + check_company=True, ) account_credit = fields.Many2one( "account.account", "Credit Account", domain=[("deprecated", "=", False)], company_dependent=True, + check_company=True, ) tax_base_id = fields.Many2one("hr.salary.rule", "Base") tax_line_ids = fields.One2many("hr.salary.rule", "tax_base_id", string="Tax lines") diff --git a/payroll_account/readme/CONFIGURE.md b/payroll_account/readme/CONFIGURE.md index 3d8138ba9..4ce38b42b 100644 --- a/payroll_account/readme/CONFIGURE.md +++ b/payroll_account/readme/CONFIGURE.md @@ -1,3 +1,24 @@ -\# Go to *Payroll \> Configuration \> Salary Rules* \# Select rule that -you want in accounting \# Go to *Accounting Tab* and select Debit, -Credit Account +Each salary rule carries its own accounting configuration, under *Payroll > +Configuration > Salary Rules*, tab *Accounting*: + +- **Debit Account** and **Credit Account**. A rule with neither generates no + journal item at all; a rule with only one generates a single item, and the + difference is balanced on the journal's default account (see below). Both + accounts are **company-dependent**: in a multi-company database, configure + them once per company. +- **Analytic Account**, when analytic accounting is enabled. The contract's + analytic account takes precedence over the rule's. +- **Tax** and **Base**, to let the generated items carry the tax, its + repartition line and its tax grids. + +The journal the entry lands in is taken from, in order of precedence: + +1. the **payslip's own journal**, which can be changed on the payslip; +2. the **batch's journal** (*Salary Journal* on the payslip batch), which is + what the payslips of a batch are created with; +3. the **contract's journal** (*Accounting* group of the contract form). + +Give that journal a **default account** (*Accounting > Configuration > +Journals*): it is the account the payslip's rounding/adjustment item is booked +on whenever the rules alone do not balance. Without it, confirming such a +payslip raises an error. diff --git a/payroll_account/readme/CONTRIBUTORS.md b/payroll_account/readme/CONTRIBUTORS.md index 394233a5e..e2718a7c8 100644 --- a/payroll_account/readme/CONTRIBUTORS.md +++ b/payroll_account/readme/CONTRIBUTORS.md @@ -1,3 +1,4 @@ - Odoo SA \<\> - Saran Lim. \<\> - Daniel Reis \<\> +- Nimarosa \<\> diff --git a/payroll_account/readme/USAGE.md b/payroll_account/readme/USAGE.md new file mode 100644 index 000000000..fac3921f0 --- /dev/null +++ b/payroll_account/readme/USAGE.md @@ -0,0 +1,23 @@ +Confirming a payslip generates **one** accounting entry, posted in the +payslip's journal, and links it to the payslip -- the *Journal Entry* button on +the payslip form opens it. Every salary rule with a debit and/or a credit +account contributes an item; the entry is dated with the payslip's *Date +Account*, falling back to the end of the period. + +Partners are set only on the items whose account tracks an external +relationship: the employee on receivable and current-liability accounts, the +contribution register's partner on payable ones. Expense and income items carry +no partner. + +Confirming an already-confirmed payslip does nothing: the payslip keeps the +entry it already has, instead of booking every amount twice. + +**Cancelling** a payslip does not delete its entry. A posted entry is reversed, +and both the entry and its reversal stay attached to the payslip for the audit +trail; an entry that was never posted is simply dropped. Confirming the payslip +again generates a new entry. A payslip that still carries a posted entry cannot +be deleted -- cancel it first. + +A **refund** (*Refund* on a confirmed payslip) is an ordinary payslip with +inverted amounts, so it generates its own entry, with debits and credits the +other way round. diff --git a/payroll_account/static/description/index.html b/payroll_account/static/description/index.html index 4bc02a3eb..16ed41bf8 100644 --- a/payroll_account/static/description/index.html +++ b/payroll_account/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Payroll Accounting -
+
+

Payroll Accounting

- - -Odoo Community Association - -
-

Payroll Accounting

-

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

Generic Payroll system Integrated with Accounting.

This module is a backport from Odoo SA and as such, it is not included in the OCA CLA. That means we do not have a copy of the copyright on it @@ -383,23 +378,67 @@

Payroll Accounting

-

Configuration

-

# Go to Payroll > Configuration > Salary Rules # Select rule that you -want in accounting # Go to Accounting Tab and select Debit, Credit -Account

+

Configuration

+

Each salary rule carries its own accounting configuration, under +Payroll > Configuration > Salary Rules, tab Accounting:

+
    +
  • Debit Account and Credit Account. A rule with neither +generates no journal item at all; a rule with only one generates a +single item, and the difference is balanced on the journal’s default +account (see below). Both accounts are company-dependent: in a +multi-company database, configure them once per company.
  • +
  • Analytic Account, when analytic accounting is enabled. The +contract’s analytic account takes precedence over the rule’s.
  • +
  • Tax and Base, to let the generated items carry the tax, its +repartition line and its tax grids.
  • +
+

The journal the entry lands in is taken from, in order of precedence:

+
    +
  1. the payslip’s own journal, which can be changed on the payslip;
  2. +
  3. the batch’s journal (Salary Journal on the payslip batch), +which is what the payslips of a batch are created with;
  4. +
  5. the contract’s journal (Accounting group of the contract form).
  6. +
+

Give that journal a default account (Accounting > Configuration > +Journals): it is the account the payslip’s rounding/adjustment item is +booked on whenever the rules alone do not balance. Without it, +confirming such a payslip raises an error.

+
+
+

Usage

+

Confirming a payslip generates one accounting entry, posted in the +payslip’s journal, and links it to the payslip – the Journal Entry +button on the payslip form opens it. Every salary rule with a debit +and/or a credit account contributes an item; the entry is dated with the +payslip’s Date Account, falling back to the end of the period.

+

Partners are set only on the items whose account tracks an external +relationship: the employee on receivable and current-liability accounts, +the contribution register’s partner on payable ones. Expense and income +items carry no partner.

+

Confirming an already-confirmed payslip does nothing: the payslip keeps +the entry it already has, instead of booking every amount twice.

+

Cancelling a payslip does not delete its entry. A posted entry is +reversed, and both the entry and its reversal stay attached to the +payslip for the audit trail; an entry that was never posted is simply +dropped. Confirming the payslip again generates a new entry. A payslip +that still carries a posted entry cannot be deleted – cancel it first.

+

A refund (Refund on a confirmed payslip) is an ordinary payslip +with inverted amounts, so it generates its own entry, with debits and +credits the other way round.

-

Bug Tracker

+

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 @@ -407,23 +446,24 @@

Bug Tracker

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

-
diff --git a/payroll_account/tests/test_payroll_account.py b/payroll_account/tests/test_payroll_account.py index af23aec6a..f2e776732 100644 --- a/payroll_account/tests/test_payroll_account.py +++ b/payroll_account/tests/test_payroll_account.py @@ -4,7 +4,8 @@ from dateutil import relativedelta -from odoo import fields +from odoo import Command, fields +from odoo.exceptions import UserError from odoo.tests import common @@ -36,7 +37,7 @@ def setUp(self): "gender": "male", "marital": "single", "name": "John", - "bank_account_id": self.res_partner_bank.bank_id.id, + "bank_account_id": self.res_partner_bank.id, } ) @@ -219,3 +220,329 @@ def test_partner_logic_account_types(self): # Test other account types -> no partner self.account_credit.account_type = "expense" self.assertFalse(line._get_partner_id(True)) + + def test_partner_falls_back_to_the_bank_account_partner(self): + """Without a work contact, the employee's bank account names the partner.""" + register_partner = self.env["res.partner"].create({"name": "Tax Authority"}) + register = self.env["hr.contribution.register"].create( + {"name": "Tax Register", "partner_id": register_partner.id} + ) + rule = self.env.ref("payroll.hr_salary_rule_houserentallowance1") + rule.register_id = register + payslip = self._prepare_payslip(self.hr_employee_john) + line = self.env["hr.payslip.line"].create( + {"slip_id": payslip.id, "salary_rule_id": rule.id, "name": "Test"} + ) + self.hr_employee_john.work_contact_id = False + self.account_credit.account_type = "asset_receivable" + rule.account_credit = self.account_credit + + self.assertEqual( + line._get_partner_id(True), self.res_partner_bank.partner_id.id + ) + + # ------------------------------------------------------------------ + # Company consistency + # ------------------------------------------------------------------ + def _other_company(self): + company = self.env["res.company"].create({"name": "Payroll Other Company"}) + self.env.user.company_ids = [Command.link(company.id)] + return company + + def test_accounts_are_read_in_the_payslip_company(self): + """Debit/credit accounts are company-dependent. + + They must be resolved in the payslip's company, not in whatever + company the confirming user happens to be working in. + """ + self._update_account_in_rule(self.account_debit, self.account_credit) + payslip = self._prepare_payslip(self.hr_employee_john) + other_company = self._other_company() + + payslip.with_company(other_company).action_payslip_done() + + self.assertTrue( + payslip.move_id, + "The accounting entry must be generated with the accounts configured " + "for the payslip's company", + ) + accounts = payslip.move_id.line_ids.account_id + self.assertIn(self.account_debit, accounts) + self.assertIn(self.account_credit, accounts) + + def test_move_belongs_to_the_payslip_company(self): + self._update_account_in_rule(self.account_debit, self.account_credit) + payslip = self._prepare_payslip(self.hr_employee_john) + other_company = self._other_company() + + payslip.with_company(other_company).action_payslip_done() + + move = payslip.move_id + self.assertEqual(move.company_id, payslip.company_id) + self.assertEqual(move.journal_id, payslip.journal_id) + + def test_move_is_balanced_posted_and_dated(self): + self._update_account_in_rule(self.account_debit, self.account_credit) + payslip = self._prepare_payslip(self.hr_employee_john) + + payslip.action_payslip_done() + + move = payslip.move_id + self.assertEqual(move.state, "posted") + self.assertEqual(move.ref, payslip.number) + self.assertEqual(move.date, payslip.date) + self.assertEqual( + sum(move.line_ids.mapped("debit")), + sum(move.line_ids.mapped("credit")), + "The generated entry must be balanced", + ) + + def test_batch_default_journal_belongs_to_the_active_company(self): + other_company = self._other_company() + other_journal = self.env["account.journal"].create( + { + "name": "Salaries - Other Company", + "code": "SALOC", + "type": "general", + "company_id": other_company.id, + } + ) + + defaults = ( + self.env["hr.payslip.run"] + .with_company(other_company) + .default_get(["journal_id"]) + ) + + self.assertEqual(defaults.get("journal_id"), other_journal.id) + + def test_tax_details_with_several_matching_repartition_lines(self): + """A tax may spread over several repartition lines on the same account. + + Reading ``.id`` off that recordset raises, so the lookup has to be + limited to one record. + """ + tax = self.env["account.tax"].create( + { + "name": "Payroll Tax", + "amount_type": "fixed", + "amount": 0.0, + "type_tax_use": "purchase", + "invoice_repartition_line_ids": [ + Command.create({"repartition_type": "base"}), + Command.create( + { + "repartition_type": "tax", + "factor_percent": 50.0, + "account_id": self.account_debit.id, + } + ), + Command.create( + { + "repartition_type": "tax", + "factor_percent": 50.0, + "account_id": self.account_debit.id, + } + ), + ], + "refund_repartition_line_ids": [ + Command.create({"repartition_type": "base"}), + Command.create( + { + "repartition_type": "tax", + "factor_percent": 50.0, + "account_id": self.account_debit.id, + } + ), + Command.create( + { + "repartition_type": "tax", + "factor_percent": 50.0, + "account_id": self.account_debit.id, + } + ), + ], + } + ) + rule = self.env.ref("payroll.hr_salary_rule_houserentallowance1") + self._update_account_in_rule(self.account_debit, self.account_credit) + rule.account_tax_id = tax + payslip = self._prepare_payslip(self.hr_employee_john) + line = self.env["hr.payslip.line"].create( + {"slip_id": payslip.id, "salary_rule_id": rule.id, "name": "Test"} + ) + + _tax_ids, tax_tag_ids, tax_repartition_line_id = payslip._get_tax_details(line) + + self.assertIn( + tax_repartition_line_id, + tax.invoice_repartition_line_ids.ids, + "A single invoice repartition line of the tax must be selected", + ) + self.assertTrue(tax_tag_ids is False or isinstance(tax_tag_ids, list)) + + # ------------------------------------------------------------------ + # Accounting entry life cycle + # ------------------------------------------------------------------ + def _allow_cancelling_payslips(self): + self.env["ir.config_parameter"].sudo().set_param( + "payroll.allow_cancel_payslips", True + ) + + def _confirmed_payslip(self): + self._update_account_in_rule(self.account_debit, self.account_credit) + payslip = self._prepare_payslip(self.hr_employee_john) + payslip.action_payslip_done() + self.assertTrue(payslip.move_id) + return payslip + + def test_cancel_reverses_the_entry_instead_of_deleting_it(self): + self._allow_cancelling_payslips() + payslip = self._confirmed_payslip() + move = payslip.move_id + + payslip.action_payslip_cancel() + + self.assertEqual(payslip.state, "cancel") + self.assertTrue(move.exists(), "The posted entry must not be deleted") + self.assertEqual(move.state, "posted") + reversal = move.reversal_move_ids + self.assertEqual(len(reversal), 1, "The entry must have been reversed") + self.assertEqual(reversal.reversed_entry_id, move) + self.assertEqual( + sum(reversal.line_ids.mapped("balance")), + -sum(move.line_ids.mapped("balance")), + ) + self.assertIn(move, payslip.cancelled_move_ids) + self.assertIn(reversal, payslip.cancelled_move_ids) + self.assertFalse(payslip.move_id) + + def test_confirming_twice_does_not_create_a_second_entry(self): + payslip = self._confirmed_payslip() + move = payslip.move_id + move_count = self.env["account.move"].search_count([]) + + payslip.action_payslip_done() + + self.assertEqual(payslip.move_id, move) + self.assertEqual(self.env["account.move"].search_count([]), move_count) + + def test_cancel_then_confirm_again_creates_a_new_entry(self): + self._allow_cancelling_payslips() + payslip = self._confirmed_payslip() + first_move = payslip.move_id + payslip.action_payslip_cancel() + payslip.action_payslip_draft() + + payslip.action_payslip_done() + + self.assertTrue(payslip.move_id) + self.assertNotEqual(payslip.move_id, first_move) + + def test_cannot_delete_a_payslip_with_a_posted_entry(self): + payslip = self._confirmed_payslip() + payslip.action_payslip_draft() + + with self.assertRaises(UserError): + payslip.unlink() + + def test_cancel_deletes_an_entry_that_was_never_posted(self): + self._allow_cancelling_payslips() + payslip = self._confirmed_payslip() + move = payslip.move_id + move.button_draft() + + payslip.action_payslip_cancel() + + self.assertFalse(move.exists()) + self.assertFalse(payslip.move_id) + + # ------------------------------------------------------------------ + # Journal + # ------------------------------------------------------------------ + def _new_journal(self, code="SALT2"): + return self.env["account.journal"].create( + {"name": f"Salaries {code}", "code": code, "type": "general"} + ) + + def _new_payslip(self, **vals): + values = { + "employee_id": self.hr_employee_john.id, + "contract_id": self.hr_contract_john.id, + "struct_id": self.hr_structure_softwaredeveloper.id, + "name": "Payslip for John", + } + values.update(vals) + return self.env["hr.payslip"].create(values) + + def test_journal_defaults_to_the_contract_journal(self): + payslip = self._new_payslip() + + self.assertEqual(payslip.journal_id, self.account_journal) + + def test_journal_is_editable_on_a_single_payslip(self): + self.assertFalse( + self.env["hr.payslip"]._fields["journal_id"].readonly, + "The form marks the journal as required, so it has to be editable", + ) + payslip = self._new_payslip() + other_journal = self._new_journal() + + payslip.journal_id = other_journal + + self.assertEqual(payslip.journal_id, other_journal) + self.assertEqual( + self.hr_contract_john.journal_id, + self.account_journal, + "Choosing a journal on one payslip must not touch the contract", + ) + + def test_changing_the_contract_journal_leaves_existing_payslips_alone(self): + payslip = self._new_payslip() + other_journal = self._new_journal(code="SALT3") + + self.hr_contract_john.journal_id = other_journal + + self.assertEqual( + payslip.journal_id, + self.account_journal, + "An existing payslip must keep the journal it was created with", + ) + + def test_batch_journal_wins_over_the_contract_journal(self): + batch_journal = self._new_journal(code="SALT4") + payslip_run = self.env["hr.payslip.run"].create( + {"name": "Batch", "journal_id": batch_journal.id} + ) + + payslip = self._new_payslip(payslip_run_id=payslip_run.id) + + self.assertEqual(payslip.journal_id, batch_journal) + + def test_default_journal_id_from_the_context_is_honoured(self): + other_journal = self._new_journal(code="SALT5") + + payslip = ( + self.env["hr.payslip"] + .with_context(default_journal_id=other_journal.id) + .create( + { + "employee_id": self.hr_employee_john.id, + "contract_id": self.hr_contract_john.id, + "struct_id": self.hr_structure_softwaredeveloper.id, + "name": "Payslip for John", + } + ) + ) + + self.assertEqual(payslip.journal_id, other_journal) + + def test_accounting_entry_button_opens_the_move(self): + self._update_account_in_rule(self.account_debit, self.account_credit) + payslip = self._prepare_payslip(self.hr_employee_john) + payslip.action_payslip_done() + + action = payslip.action_open_accounting_entry() + + self.assertEqual(action["res_model"], "account.move") + self.assertEqual(action["res_id"], payslip.move_id.id) diff --git a/payroll_account/views/hr_payroll_account_views.xml b/payroll_account/views/hr_payroll_account_views.xml index ea1ca52dd..743677e52 100644 --- a/payroll_account/views/hr_payroll_account_views.xml +++ b/payroll_account/views/hr_payroll_account_views.xml @@ -7,8 +7,20 @@ - +
+ +