# Part of Odoo. See LICENSE file for full copyright and licensing details. import re from dateutil.relativedelta import relativedelta from freezegun import freeze_time from odoo import Command from odoo.tests import tagged from odoo.exceptions import UserError from odoo.addons.hr_expense.tests.common import TestExpenseCommon from odoo.addons.hr_payroll_account.tests.test_hr_payroll_account import TestHrPayrollAccountCommon @tagged('post_install', '-at_install') class TestPayrollExpense(TestHrPayrollAccountCommon, TestExpenseCommon): @classmethod def setUpClass(cls): super().setUpClass() cls.env.user.group_ids |= cls.env.ref('hr_payroll.group_hr_payroll_manager') cls.env = cls.env(context=dict(cls.env.context, allowed_company_ids=[cls.company_data['company'].id])) cls.company.tax_calculation_rounding_method = 'round_per_line' cls.payslip_run.company_id = cls.company_data['company'].id # Else the payslip_run will be using the the demo company in its environment, thus raising an error # when payslip_run.action_validate() is called. Because the demo company doesn't have a journal # and payslip_run.slip_ids.journal_id is company dependent cls.payslip_run.env = cls.env cls.expense_employee.update({ 'sex': 'male', 'birthday': '1984-05-01', 'company_id': cls.company_data['company'].id, 'country_id': cls.company_data['company'].country_id.id, 'department_id': cls.dep_rd.id, 'date_version': cls.frozen_today - relativedelta(years=2), 'contract_date_start': cls.frozen_today - relativedelta(years=2), 'contract_date_end': cls.frozen_today + relativedelta(years=2), 'wage': 5000.33, 'structure_type_id': cls.hr_structure_type.id, }) cls.expense_contract = cls.expense_employee.version_id work_entry_vals = cls.env['hr.version']._generate_work_entries_postprocess([{ 'name': 'Work Entry', 'employee_id': cls.expense_employee.id, 'date_start': cls.frozen_today - relativedelta(days=1), 'date_stop': cls.frozen_today, 'version_id': cls.expense_contract.id, 'state': 'validated', }]) cls.expense_work_entry = cls.env['hr.work.entry'].create(work_entry_vals) cls.expense_payslip_input = cls.env.ref('hr_payroll_expense.expense_other_input') expense_payslip_tax_account = cls.env['account.account'].create({ 'name': 'Rental Tax', 'code': '777777', 'account_type': 'asset_current', }) expense_tax = cls.env['account.tax'].create({ 'name': "Some taxes on normal payslip", 'amount_type': 'percent', 'amount': 10.0, 'type_tax_use': 'sale', 'company_id': cls.company_data['company'].id, 'invoice_repartition_line_ids': [ Command.create({'factor_percent': 100, 'repartition_type': 'base'}), Command.create({'factor_percent': 100, 'account_id': expense_payslip_tax_account.id}), ], 'refund_repartition_line_ids': [ Command.create({'factor_percent': 100, 'repartition_type': 'base'}), Command.create({'factor_percent': 100, 'account_id': expense_payslip_tax_account.id}), ], }) test_account = cls.env['account.account'].create({ 'name': 'House Rental', 'code': '654321', 'account_type': 'income', 'tax_ids': [Command.link(expense_tax.id)], }) cls.expense_payable_account = cls.env['account.account'].create({ 'name': 'payable', 'code': '654323', 'account_type': 'liability_payable', }) cls.expense_payslip_journal = cls.env['account.journal'].create({ 'name': 'EXPENSE', 'code': 'EXP', 'type': 'general', 'company_id': cls.company_data['company'].id, }) cls.expense_hr_structure = cls.env['hr.payroll.structure'].create({ 'name': 'Salary Structure for Software Developer', 'journal_id': cls.expense_payslip_journal.id, 'rule_ids': [Command.create({ 'name': 'Basic Salary', 'amount_select': 'code', 'amount_python_compute': 'result = version.wage', 'code': 'BASIC', 'category_id': cls.env.ref('hr_payroll.BASIC').id, 'sequence': 1, 'account_debit': test_account.id, }), Command.create({ 'name': 'House Rent Allowance', 'amount_select': 'percentage', 'amount_percentage': 40, 'amount_percentage_base': 'version.wage', 'code': 'HRA', 'category_id': cls.env.ref('hr_payroll.ALW').id, 'sequence': 5, 'account_debit': test_account.id, }), Command.create({ 'name': 'Reimbursed Expenses', 'amount_select': 'input', 'condition_other_input_id': cls.expense_payslip_input.id, 'amount_other_input_id': cls.expense_payslip_input.id, 'code': 'EXPENSES', 'category_id': cls.env.ref('hr_payroll.ALW').id, 'sequence': 6, 'account_debit': cls.expense_payable_account.id, }), Command.create({ 'name': 'Net Salary', 'amount_select': 'code', 'amount_python_compute': 'result = categories["BASIC"] + categories["ALW"] + categories["DED"]', 'code': 'NET', 'category_id': cls.env.ref('hr_payroll.NET').id, 'sequence': 10, 'account_credit': test_account.id, })], 'type_id': cls.env['hr.payroll.structure.type'].create({'name': 'Employee', 'country_id': False}).id, }) cls.expense_salary_rule = cls.expense_hr_structure.rule_ids.filtered(lambda rule: rule.code == 'EXPENSES') def create_payslip(self, vals=None): return self.env['hr.payslip'].create({ 'name': 'Payslip', 'employee_id': self.expense_employee.id, 'struct_id': self.expense_hr_structure.id, 'version_id': self.expense_contract.id, 'payslip_run_id': self.payslip_run.id, 'date_from': self.frozen_today - relativedelta(months=1), 'date_to': self.frozen_today, 'company_id': self.company_data['company'].id, **(vals or {}) }) def get_reconciliation_lines_from_accounts(self, account_ids): """ Helper function to return reconciled lines linked to specific accounts """ return self.env['account.partial.reconcile'].search([ '|', ('debit_move_id.account_id', 'in', account_ids), ('credit_move_id.account_id', 'in', account_ids), ]) @staticmethod def get_all_amls_to_be_reconciled(expenses, payslips, include_payslips_payment_terms_amls=False): """ Helper function to return all the amls required to test the reconciliation """ expenses_amls = expenses.account_move_id.line_ids.filtered(lambda line: line.display_type == 'payment_term') payslip_payable_lines = payslips.move_id.line_ids.filtered(lambda line: line.account_type == 'liability_payable') if include_payslips_payment_terms_amls: return expenses_amls, payslip_payable_lines payslip_payment_terms = payslip_payable_lines.filtered(lambda line: line.display_type == 'payment_term') payslip_expense_lines = payslip_payable_lines - payslip_payment_terms return expenses_amls, payslip_expense_lines @freeze_time('2022-01-25') def test_main_flow_expense_in_payslip(self): expense_1 = self.create_expenses({'name': 'Expense'}) expense_2 = self.create_expenses({ 'name': 'Expense 2', 'total_amount_currency': 3000, }) expenses = expense_1 | expense_2 expenses_total_amount = sum(expenses.mapped('total_amount')) expenses.action_submit() expenses.action_approve() for expense in expenses: # Ensure each expense as a unique move self.post_expenses_with_wizard(expense) expenses.action_report_in_next_payslip() # Creating payslip links the expense to the payslip payslip = self.create_payslip() self.assertRecordValues(expenses, [ {'payslip_id': payslip.id, 'state': 'posted', 'account_move_id': expense_1.account_move_id.id}, {'payslip_id': payslip.id, 'state': 'posted', 'account_move_id': expense_2.account_move_id.id}, ]) self.assertRecordValues(payslip, [ {'expense_ids': expenses.ids, 'state': 'draft', 'employee_id': self.expense_employee.id}, ]) self.assertRecordValues(payslip.input_line_ids, [ {'input_type_id': self.expense_payslip_input.id, 'amount': expenses_total_amount}, ]) # Test removing expense from payslip unlinks the two expense_1.action_remove_from_payslip() self.assertRecordValues(expenses, [ {'name': 'Expense', 'state': 'posted', 'payslip_id': False, 'account_move_id': expense_1.account_move_id.id}, {'name': 'Expense 2', 'state': 'posted', 'payslip_id': payslip.id, 'account_move_id': expense_2.account_move_id.id}, ]) self.assertRecordValues(payslip, [ {'expense_ids': expense_2.ids, 'state': 'draft', 'employee_id': self.expense_employee.id}, ]) self.assertRecordValues(payslip.input_line_ids, [ {'input_type_id': self.expense_payslip_input.id, 'amount': expense_2.total_amount}, ]) expense_2.action_remove_from_payslip() self.assertRecordValues(expenses, [ {'state': 'posted', 'payslip_id': False, 'account_move_id': expense_1.account_move_id.id}, {'state': 'posted', 'payslip_id': False, 'account_move_id': expense_2.account_move_id.id}, ]) self.assertRecordValues(payslip, [ {'expense_ids': [], 'state': 'draft', 'employee_id': self.expense_employee.id}, ]) self.assertFalse(payslip.input_line_ids) # This should re-add the expense to the payslip expenses.action_report_in_next_payslip() payslip.action_payslip_draft() self.assertRecordValues(expenses, [ {'state': 'posted', 'payslip_id': payslip.id, 'account_move_id': expense_1.account_move_id.id}, {'state': 'posted', 'payslip_id': payslip.id, 'account_move_id': expense_2.account_move_id.id}, ]) # Moving up to setting the payslip as done shouldn't change anything for the expense self.payslip_run.slip_ids.compute_sheet() self.payslip_run.action_validate() self.assertRecordValues(expenses, [ {'state': 'posted', 'payslip_id': payslip.id, 'account_move_id': expense_1.account_move_id.id}, {'state': 'posted', 'payslip_id': payslip.id, 'account_move_id': expense_2.account_move_id.id}, ]) # Test trying to remove the expense from the payslip when a payslip has generated a move raises an error with self.assertRaises(UserError): expense_1.action_remove_from_payslip() with self.assertRaises(UserError): expense_1.action_reset() # Posting the payslip move should post the expense moves payslip.move_id.action_post() expenses_lines_to_reconcile, payslip_lines_to_reconcile = \ self.get_all_amls_to_be_reconciled(expenses, payslip, include_payslips_payment_terms_amls=True) self.assertRecordValues(expenses_lines_to_reconcile.sorted('balance'), [ {'balance': -expense_2.total_amount}, {'balance': -expense_1.total_amount}, ]) self.assertRecordValues(payslip_lines_to_reconcile, [ {'balance': expenses_total_amount, 'account_id': self.expense_payable_account.id}, ]) reconciliation_lines = self.get_reconciliation_lines_from_accounts( (expenses_lines_to_reconcile | payslip_lines_to_reconcile).account_id.ids ) self.assertTrue(reconciliation_lines, "There should be automatic reconciliation lines as it's the most simple case") # Because the expense & the payslip moves don't have the same account, there should be a misc entry generated misc_move = reconciliation_lines.debit_move_id.move_id - (expenses.account_move_id | payslip.move_id) self.assertEqual( len(misc_move), 1, "Because the expense & the payslip moves don't have the same account, there should be a misc entry generated", ) misc_move_lines = misc_move.line_ids.sorted('balance') self.assertRecordValues(reconciliation_lines.sorted('amount'), [ {'amount': expense_1.total_amount, 'debit_move_id': misc_move_lines.ids[1], 'credit_move_id': expenses_lines_to_reconcile.ids[0]}, {'amount': expense_2.total_amount, 'debit_move_id': misc_move_lines.ids[1], 'credit_move_id': expenses_lines_to_reconcile.ids[1]}, {'amount': expenses_total_amount, 'debit_move_id': payslip_lines_to_reconcile.id, 'credit_move_id': misc_move_lines.ids[0]}, ]) # Test reversing the payslip move keeps the expense linked to the payslip payslip.move_id.button_draft() payslip.move_id.unlink() self.assertRecordValues(expenses, [ {'state': 'approved', 'payslip_id': payslip.id, 'account_move_id': False}, {'state': 'approved', 'payslip_id': payslip.id, 'account_move_id': False}, ]) payslip.action_payslip_draft() payslip.unlink() self.assertRecordValues(expenses, [ {'state': 'approved', 'payslip_id': False, 'account_move_id': False}, {'state': 'approved', 'payslip_id': False, 'account_move_id': False}, ]) @freeze_time('2022-01-25') def test_corner_case_expense_with_expense_payslip_same_payable_account(self): """ Test posting the payslip move, in the case where the payable account is the same for both the payslip & the expense moves """ expense = self.create_expenses() expense.action_submit() expense.action_approve() self.post_expenses_with_wizard(expense) expense.action_report_in_next_payslip() self.assertRecordValues(expense, [ {'total_amount': 1000.0, 'state': 'posted'}, ]) self.assertRecordValues(expense.account_move_id, [ {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) # Sets the expense rule on the payroll expense rule self.expense_salary_rule.account_debit = expense._get_expense_account_destination() payslip = self.create_payslip() self.assertEqual( 1000.0, payslip.input_line_ids.filtered(lambda rule: rule.code == 'EXPENSES').amount, "The expense total amount should be added to the new payslip expense input line", ) payslip.compute_sheet() payslip.action_payslip_done() payslip.move_id.action_post() # Check reconciliation # Get the corresponding account.partial.reconcile lines expense_line_to_reconcile, payslip_line_to_reconcile = self.get_all_amls_to_be_reconciled(expense, payslip) reconciliation_lines = self.get_reconciliation_lines_from_accounts([expense._get_expense_account_destination()]) misc_move = reconciliation_lines.debit_move_id.move_id - (expense.account_move_id | payslip.move_id) self.assertFalse( misc_move, "Because the expense & the payslip moves have the same account, there should be no misc entry generated", ) self.assertRecordValues(reconciliation_lines.sorted('amount'), [ {'amount': 1000.0, 'debit_move_id': payslip_line_to_reconcile.id, 'credit_move_id': expense_line_to_reconcile.id}, ]) @freeze_time('2022-01-25') def test_corner_case_expense_with_all_identical_payable_accounts(self): """ Similar case than `test_corner_case_expense_with_expense_payslip_same_payable_account` except that the payslip payment term account is also the same """ expense = self.create_expenses() expense.action_submit() expense.action_approve() self.post_expenses_with_wizard(expense) expense.action_report_in_next_payslip() self.assertRecordValues(expense, [ {'total_amount': 1000.0, 'state': 'posted'}, ]) self.assertRecordValues(expense.account_move_id, [ {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) # Sets the expense rule on the payroll expense rule & NET rule self.expense_salary_rule.account_debit = expense._get_expense_account_destination() net_rule = self.expense_hr_structure.rule_ids.filtered(lambda rule: rule.code == 'NET') net_rule.account_credit = expense._get_expense_account_destination() payslip = self.create_payslip() self.assertEqual( 1000.0, payslip.input_line_ids.filtered(lambda rule: rule.code == 'EXPENSES').amount, "The expense total amount should be added to the new payslip expense input line", ) payslip.compute_sheet() payslip.action_payslip_done() payslip.move_id.action_post() # Check reconciliation # Get the corresponding account.partial.reconcile lines reconciliation_lines = self.get_reconciliation_lines_from_accounts([expense._get_expense_account_destination()]) misc_move = reconciliation_lines.debit_move_id.move_id - (expense.account_move_id | payslip.move_id) self.assertFalse( misc_move, "Because the expense & the payslip moves have the same account, there should be no misc entry generated", ) expense_line_to_reconcile, payslip_line_to_reconcile = \ self.get_all_amls_to_be_reconciled(expense, payslip, include_payslips_payment_terms_amls=True) payslip_line_reconciled = payslip_line_to_reconcile.filtered('reconciled') self.assertEqual(len(payslip_line_reconciled), 1, "Only one line should be reconciled") self.assertEqual( payslip_line_reconciled.name, 'Reimbursed Expenses', 'The expected Expense line should be the one that is reconciled', ) self.assertRecordValues(reconciliation_lines.sorted('amount'), [ {'amount': 1000.0, 'debit_move_id': payslip_line_reconciled.id, 'credit_move_id': expense_line_to_reconcile.id}, ]) @freeze_time('2022-01-25') def test_corner_case_expense_edited_expense_move(self): """ Test that, as you can edit the expense account move independently of the expense, it will still prepare the reconciliation if the totals do not match (as it would require a write-off misc move & user input to select a write-off account) """ expenses = self.create_expenses([ { 'name': 'Expense To Keep', '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)], 'date': '2022-01-26', 'company_id': self.company_data['company'].id, 'currency_id': self.company_data['currency'].id, }, { 'name': 'Expense To Edit', 'employee_id': self.expense_employee.id, 'product_id': self.product_c.id, 'total_amount_currency': 2000.00, 'tax_ids': [Command.set(self.tax_purchase_a.ids)], 'date': '2022-01-25', 'company_id': self.company_data['company'].id, 'currency_id': self.company_data['currency'].id, }, ]) expenses.action_submit() expenses.action_approve() self.post_expenses_with_wizard(expenses) expenses.action_report_in_next_payslip() self.assertRecordValues(expenses, [ {'total_amount': 1000.0, 'state': 'posted'}, {'total_amount': 2000.0, 'state': 'posted'}, ]) self.assertRecordValues(expenses.account_move_id, [ {'amount_total': 3000.0, 'amount_residual': 3000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) # Edit the total_amount on the expense move by duplicating the product line line_to_duplicate = expenses.account_move_id.line_ids.filtered(lambda line: 'Expense To Edit' in (line.name or ""))[:1] line_to_duplicate.move_id.button_draft() line_to_duplicate.copy() line_to_duplicate.move_id.action_post() self.assertRecordValues(expenses.account_move_id, [ {'amount_total': 5000.0, 'amount_residual': 5000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) payslip = self.create_payslip() payslip.compute_sheet() payslip.action_payslip_done() self.assertEqual( 3000, payslip.input_line_ids.filtered(lambda rule: rule.code == 'EXPENSES').amount, "The expenses total amount should be added to the new payslip expense input line", ) payslip.move_id.action_post() # Check the two moves are NOT reconciled # Get the corresponding account.partial.reconcile lines expense_lines_to_reconcile, payslip_line_to_reconcile = self.get_all_amls_to_be_reconciled(expenses, payslip) reconciliation_lines = self.get_reconciliation_lines_from_accounts( (expense_lines_to_reconcile | payslip_line_to_reconcile).account_id.ids ) self.assertFalse( reconciliation_lines, "Because the expense & the payslip moves don't have the same amount, there should be no reconciliation", ) self.assertRecordValues(expenses, [ {'state': 'posted'}, {'state': 'posted'}, ]) self.assertRecordValues(expenses.account_move_id, [ {'payment_state': 'not_paid', 'amount_residual': 5000.00}, ]) self.assertSequenceEqual( [f'I0000000000-{payslip.move_id.id}-{min(expenses.account_move_id.ids)}'] * 2, [*expense_lines_to_reconcile.mapped('matching_number'), payslip_line_to_reconcile.matching_number], "A temporary matching number should still be present on the account move lines to help manually reconcile them" ) def test_already_paid_expense(self): """ Test that you can post the move of your payslip, even if the expense has been flagged to be reimbursed through a payslip, but still got paid the "common way" """ paid_or_in_payment_state = self.env['account.move']._get_invoice_in_payment_state() expense_paid_before_payslip_creation = self.create_expenses() expense_paid_before_payslip_move_posting = self.create_expenses() expense_normal = self.create_expenses() expenses = expense_paid_before_payslip_creation | expense_paid_before_payslip_move_posting | expense_normal expenses.action_submit() expenses._do_approve() # Skip duplicate wizard for expense in expenses: # Ensure we get three distinct moves self.post_expenses_with_wizard(expense) expenses.action_report_in_next_payslip() self.assertRecordValues(expenses, [ {'total_amount': 1000.0, 'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, {'total_amount': 1000.0, 'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, {'total_amount': 1000.0, 'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, ]) self.assertRecordValues(expenses.account_move_id, [ {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) self.get_new_payment(expense_paid_before_payslip_creation, 1000.0) self.assertRecordValues(expenses.sorted('state'), [ # refund_in_payslip flag isn't reset, but we currently do not need it to be {'total_amount': 1000.0, 'state': paid_or_in_payment_state, 'refund_in_payslip': True, 'payslip_id': False}, {'total_amount': 1000.0, 'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, {'total_amount': 1000.0, 'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, ]) self.assertRecordValues(expenses.account_move_id.sorted('payment_state'), [ {'amount_total': 1000.0, 'amount_residual': 0.0, 'payment_state': paid_or_in_payment_state, 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) payslip = self.create_payslip() self.assertEqual( 2000.0, payslip.input_line_ids.filtered(lambda rule: rule.code == 'EXPENSES').amount, "The expense total amount of the two expenses in 'approve' state should be added to the new payslip expense input line", ) payslip.compute_sheet() payslip.action_payslip_done() ctx = {'active_model': 'account.move', 'active_ids': expense_paid_before_payslip_move_posting.account_move_id.ids} payment_register = self.env['account.payment.register'].with_context(**ctx).create({ 'amount': expense_paid_before_payslip_move_posting.total_amount, 'journal_id': self.company_data['default_journal_bank'].id, 'payment_method_line_id': self.inbound_payment_method_line.id, }) self.assertTrue( payment_register.is_already_paid_through_a_payslip, "We should have a warning that it's a bad idea to pay twice", ) payment_register._create_payments() self.assertEqual( 2000, payslip.input_line_ids.filtered(lambda rule: rule.code == 'EXPENSES').amount, "The 2 linked expenses total amounts should be added to the new payslip expense input line", ) self.assertRecordValues(expenses.sorted('state'), [ {'total_amount': 1000.0, 'state': paid_or_in_payment_state, 'payslip_id': False}, {'total_amount': 1000.0, 'state': paid_or_in_payment_state, 'payslip_id': payslip.id}, {'total_amount': 1000.0, 'state': 'posted', 'payslip_id': payslip.id}, ]) self.assertRecordValues(expenses.account_move_id.sorted('state'), [ {'amount_total': 1000.0, 'amount_residual': 0.0, 'payment_state': paid_or_in_payment_state, 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 0.0, 'payment_state': paid_or_in_payment_state, 'state': 'posted'}, {'amount_total': 1000.0, 'amount_residual': 1000.0, 'payment_state': 'not_paid', 'state': 'posted'}, ]) # Posting the move will result in `expense_paid_before_payslip_move_posting` being paid twice, # but as the warning has been ignored it's the user's problem. It should not raise payslip.move_id.action_post() # Check reconciliation # Get the corresponding account.partial.reconcile lines reconciliation_lines = self.get_reconciliation_lines_from_accounts([expense_normal._get_expense_account_destination()]) misc_moves = reconciliation_lines.debit_move_id.move_id - (expenses.account_move_id | payslip.move_id) self.assertEqual( 2, len(misc_moves), "There should be two moves, corresponding to the two payments done, as the payslip won't be automatically reconciled", ) # 2 of the three lines of expense_lines_to_reconcile should be reconciled by the early payment, the last one should not expense_lines_to_reconcile, payslip_line_to_reconcile = self.get_all_amls_to_be_reconciled(expenses, payslip) self.assertFalse( payslip_line_to_reconcile.reconciled, "The payslip move should not be reconciled as one of the expense being already reconciled, there is a mismatch in the amounts", ) expense_line_not_reconciled = expense_lines_to_reconcile.filtered( lambda line: line.matching_number == payslip_line_to_reconcile.matching_number ) self.assertEqual( 1, len(expense_line_not_reconciled), "One of the expense should be prepared for manual reconciliation with the payslip move, the one that wasn't paid before posting the payslip move" ) expense_lines_reconciled = (expense_lines_to_reconcile - expense_line_not_reconciled).sorted('id') self.assertEqual( 2, len(expense_lines_reconciled), "The two other expense lines should be properly reconciled" ) pattern = re.compile(r'^\d+$') self.assertRegex(expense_lines_reconciled[0].matching_number, pattern, "The matching number should be a definitive one") self.assertRegex(expense_lines_reconciled[1].matching_number, pattern, "The matching number should be a definitive one") # Sorting by ID as there are two distinct reconciliation steps self.assertRecordValues(reconciliation_lines.sorted('id'), [ {'amount': 1000.0, 'debit_move_id': misc_moves[0].line_ids.sorted('balance').ids[-1], 'credit_move_id': expense_lines_reconciled[0].id}, {'amount': 1000.0, 'debit_move_id': misc_moves[1].line_ids.sorted('balance').ids[-1], 'credit_move_id': expense_lines_reconciled[1].id}, ]) @freeze_time('2024-01-01') def test_no_expense_rule_means_no_linkage(self): new_structure_rules = [ Command.link(rule.copy().id) for rule in self.expense_hr_structure.rule_ids.filtered(lambda rule: rule.code != 'EXPENSES') ] new_structure_rules.append(Command.create({ 'name': 'Flat rate to ensure at least one move line', 'amount_select': 'code', 'amount_python_compute': 'result = 1000', 'code': 'FLAT', 'category_id': self.env.ref('hr_payroll.BASIC').id, 'sequence': 1000, 'account_debit': self.env['account.account'].create({ 'name': 'random account', 'code': '652222', 'account_type': 'expense', 'tax_ids': [], 'company_ids': [Command.set(self.company_data['company'].ids)], }).id, })) self.expense_payslip_journal.default_account_id = self.expense_payable_account.id structure_without_expense_rule = self.expense_hr_structure.copy({ 'rule_ids': new_structure_rules, 'journal_id': self.expense_payslip_journal.id, }) expense = self.create_expenses() expense.action_submit() expense.action_approve() self.post_expenses_with_wizard(expense) expense.action_report_in_next_payslip() payslip = self.create_payslip({ 'struct_id': structure_without_expense_rule.id, }) # No expense linked if there are no expense rule in the structure self.assertFalse(payslip.expense_ids) self.payslip_run.slip_ids.compute_sheet() self.payslip_run.action_validate() payslip.move_id.action_post() self.assertRecordValues(expense, [ {'state': 'posted', 'refund_in_payslip': True, 'payslip_id': False}, ]) @freeze_time('2024-01-01') def test_unlink_payslip_moves_user(self): """ Test Account user are able to reset to draft payslip move and unlink them """ user = self.env['res.users'].create({ 'name': 'Account user', 'login': 'accountuser', 'password': 'accountuser', 'group_ids': [ Command.link(self.env.ref('account.group_account_user').id), ], }) expense = self.create_expenses() expense.action_submit() expense.action_approve() self.post_expenses_with_wizard(expense, date='2023-07-11') expense.action_report_in_next_payslip() payslip = self.create_payslip() self.payslip_run.slip_ids.compute_sheet() self.payslip_run.action_validate() payslip.move_id.action_post() payslip.move_id.with_user(user).button_draft() payslip.move_id.with_user(user).unlink() @freeze_time('2025-01-01') def test_report_in_next_payslip_manager_rights(self): with self.with_user(self.expense_user_manager.login): expense = self.create_expenses() expense.action_submit() expense.action_approve() self.post_expenses_with_wizard(expense) expense.action_report_in_next_payslip()