oca-ocb-hr/odoo-bringout-oca-ocb-hr_expense/hr_expense/tests/test_expenses.py
Ernad Husremovic a1f02d8cc7 19.0 vanilla
2026-03-25 12:00:11 +01:00

1114 lines
53 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from freezegun import freeze_time
from odoo import Command, fields
from odoo.exceptions import UserError, ValidationError
from odoo.tests import Form, tagged
from odoo.addons.hr_expense.tests.common import TestExpenseCommon
@tagged('-at_install', 'post_install')
class TestExpenses(TestExpenseCommon):
#############################################
# Test Expense flows
#############################################
def test_expense_main_flow(self):
"""
Test the main flows of expense
This includes:
- Approval flows for expense paid by company and employee up to reconciliation
- price_unit, total_amount_currency and quantity computation
- Split payments into one payment per expense when paid by company
- Override account on expense
- Payment states and payment terms
- Unlinking payments reverts to approved state
- Cannot delete an analytic account if linked to an expense
"""
self.expense_employee.user_partner_id.property_supplier_payment_term_id = self.env.ref('account.account_payment_term_30days')
expenses_by_employee = self.create_expenses([
{
'name': 'Employee PA 2*800 + 15%', # Taxes are included
'employee_id': self.expense_employee.id,
'account_id': self.expense_account.id, # Test with a specific account override
'product_id': self.product_a.id,
'quantity': 2,
'payment_mode': 'own_account',
'company_id': self.company_data['company'].id,
'date': '2021-10-14',
'analytic_distribution': {self.analytic_account_1.id: 100},
}, {
'name': 'Employee PB 160 + 2*15%', # Taxes are included
'employee_id': self.expense_employee.id,
'product_id': self.product_b.id,
'payment_mode': 'own_account',
'company_id': self.company_data['company'].id,
'date': '2021-10-13',
'analytic_distribution': {self.analytic_account_2.id: 100},
},
])
expenses_by_company = self.create_expenses([
{
'name': 'Company PC 1000 + 15%', # Taxes are included
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount_currency': 1000.00,
'date': '2021-10-12',
'payment_mode': 'company_account',
'company_id': self.company_data['company'].id,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
}, {
'name': 'Company PB 160 + 2*15%', # Taxes are included
'employee_id': self.expense_employee.id,
'product_id': self.product_b.id,
'payment_mode': 'company_account',
'company_id': self.company_data['company'].id,
'date': '2021-10-11',
},
])
all_expenses = expenses_by_employee | expenses_by_company
# Checking expense values at creation
self.assertRecordValues(all_expenses, [
{'name': 'Employee PA 2*800 + 15%', 'total_amount_currency': 1600.00, 'untaxed_amount_currency': 1391.30, 'price_unit': 800.00, 'tax_amount_currency': 208.70, 'state': 'draft'},
{'name': 'Employee PB 160 + 2*15%', 'total_amount_currency': 160.00, 'untaxed_amount_currency': 123.08, 'price_unit': 160.00, 'tax_amount_currency': 36.92, 'state': 'draft'},
{'name': 'Company PC 1000 + 15%', 'total_amount_currency': 1000.00, 'untaxed_amount_currency': 869.57, 'price_unit': 1000.00, 'tax_amount_currency': 130.43, 'state': 'draft'},
{'name': 'Company PB 160 + 2*15%', 'total_amount_currency': 160.00, 'untaxed_amount_currency': 123.08, 'price_unit': 160.00, 'tax_amount_currency': 36.92, 'state': 'draft'},
])
# Submitting properly change states
all_expenses.action_submit()
self.assertRecordValues(all_expenses, [
{'state': 'submitted'},
{'state': 'submitted'},
{'state': 'submitted'},
{'state': 'submitted'},
])
# Approving properly change states & create moves & payments
all_expenses.action_approve()
self.assertRecordValues(all_expenses, [
{'state': 'approved', 'account_move_id': False},
{'state': 'approved', 'account_move_id': False},
{'state': 'approved', 'account_move_id': False},
{'state': 'approved', 'account_move_id': False},
])
# Post a payment for 'company_account' (and its move(s)) and a receipt for 'own_account'
expenses_by_company.action_post()
self.post_expenses_with_wizard(expenses_by_employee[0], date=date(2021, 10, 10))
self.post_expenses_with_wizard(expenses_by_employee[1], date=date(2021, 10, 31))
self.assertRecordValues(all_expenses, [
# As the payment is not done yet those are still in "posted"
{'payment_mode': 'own_account', 'state': 'posted'},
{'payment_mode': 'own_account', 'state': 'posted'},
# Expenses paid by company don't use accounting date since they are already paid and posted directly
{'payment_mode': 'company_account', 'state': 'paid'},
{'payment_mode': 'company_account', 'state': 'paid'},
])
employee_partner_id = self.expense_user_employee.partner_id.id
self.assertRecordValues(expenses_by_employee.account_move_id, [
{'amount_total': 1600.00, 'ref': 'Employee PA 2*800 + 15%', 'state': 'posted', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 10), 'partner_id': employee_partner_id},
{'amount_total': 160.00, 'ref': 'Employee PB 160 + 2*15%', 'state': 'posted', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 31), 'partner_id': employee_partner_id},
])
self.assertRecordValues(expenses_by_company.account_move_id, [
{'amount_total': 1000.00, 'ref': 'Company PC 1000 + 15%', 'state': 'posted', 'date': date(2021, 10, 12), 'invoice_date': False, 'partner_id': False},
{'amount_total': 160.00, 'ref': 'Company PB 160 + 2*15%', 'state': 'posted', 'date': date(2021, 10, 11), 'invoice_date': False, 'partner_id': False},
])
tax_account_id = self.company_data['default_account_tax_purchase'].id
default_account_payable_id = self.company_data['default_account_payable'].id
product_b_account_id = self.product_b.property_account_expense_id.id
product_c_account_id = self.product_c.property_account_expense_id.id
company_payment_account_id = self.outbound_payment_method_line.payment_account_id.id
# One payment per expense
self.assertRecordValues(all_expenses.account_move_id.line_ids.sorted(lambda line: (line.move_id, line)), [
# own_account expense 1 move
{'balance': 1391.30, 'account_id': self.expense_account.id, 'name': 'expense_employee: Employee PA 2*800 + 15%', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 10)},
{'balance': 208.70, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 10)},
{'balance': -1600.00, 'account_id': default_account_payable_id, 'name': False, 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 10)},
# own_account expense 2 move
{'balance': 123.08, 'account_id': product_b_account_id, 'name': 'expense_employee: Employee PB 160 + 2*15%', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 31)},
{'balance': 18.46, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 31)},
{'balance': 18.46, 'account_id': tax_account_id, 'name': '15% (copy)', 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 31)},
{'balance': -160.00, 'account_id': default_account_payable_id, 'name': False, 'date': date(2021, 10, 31), 'invoice_date': date(2021, 10, 31)},
# company_account expense 1 move
{'balance': 869.57, 'account_id': product_c_account_id, 'name': 'expense_employee: Company PC 1000 + 15%', 'date': date(2021, 10, 12), 'invoice_date': False},
{'balance': 130.43, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 12), 'invoice_date': False},
{'balance': -1000.00, 'account_id': company_payment_account_id, 'name': 'expense_employee: Company PC 1000 + 15%', 'date': date(2021, 10, 12), 'invoice_date': False},
# company_account expense 2 move
{'balance': 123.08, 'account_id': product_b_account_id, 'name': 'expense_employee: Company PB 160 + 2*15%', 'date': date(2021, 10, 11), 'invoice_date': False},
{'balance': 18.46, 'account_id': tax_account_id, 'name': '15%', 'date': date(2021, 10, 11), 'invoice_date': False},
{'balance': 18.46, 'account_id': tax_account_id, 'name': '15% (copy)', 'date': date(2021, 10, 11), 'invoice_date': False},
{'balance': -160.00, 'account_id': company_payment_account_id, 'name': 'expense_employee: Company PB 160 + 2*15%', 'date': date(2021, 10, 11), 'invoice_date': False},
])
# Check lines partners:
self.assertRecordValues(expenses_by_employee.account_move_id.line_ids, [
# If the test fails, it is probably because the partner is the company's partner instead of the employee's one
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
{'partner_id': employee_partner_id},
])
in_payment_state = expenses_by_employee.account_move_id._get_invoice_in_payment_state()
first_expense_by_employee = expenses_by_employee[0]
first_expense_by_company = expenses_by_company[0]
# Own_account partial payment
payment_1 = self.get_new_payment(first_expense_by_employee, 1000.0)
liquidity_lines1 = payment_1._seek_for_lines()[0]
self.assertEqual(first_expense_by_employee.state, in_payment_state)
# own_account remaining payment
payment_2 = self.get_new_payment(first_expense_by_employee, 600.0)
liquidity_lines2 = payment_2._seek_for_lines()[0]
self.assertEqual(first_expense_by_employee.state, in_payment_state)
# Reconciling own_account
statement_line = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
'payment_ref': 'pay_ref',
'amount': -1600.0,
'partner_id': self.expense_employee.work_contact_id.id,
})
# Reconcile without the bank reconciliation widget since the widget is in enterprise.
_trash, st_suspense_lines, _trash = statement_line.with_context(skip_account_move_synchronization=True)._seek_for_lines()
st_suspense_lines.account_id = liquidity_lines1.account_id
(st_suspense_lines + liquidity_lines1 + liquidity_lines2).reconcile()
self.assertEqual(first_expense_by_employee.state, 'paid')
# Trying to delete analytic accounts should be forbidden if linked to an expense
with self.assertRaises(UserError):
(self.analytic_account_1 | self.analytic_account_2).unlink()
# Unlinking moves
(payment_1 | payment_2).action_draft()
(payment_1 | payment_2).move_id.line_ids.remove_move_reconcile()
self.assertEqual(first_expense_by_employee.state, 'posted')
expenses_by_employee.account_move_id.button_draft()
expenses_by_employee.account_move_id.unlink()
self.assertFalse(expenses_by_employee.account_move_id)
first_expense_by_company.account_move_id.origin_payment_id.unlink()
self.assertFalse(first_expense_by_company.account_move_id)
self.assertRecordValues(first_expense_by_employee | first_expense_by_company, [
{'payment_mode': 'own_account', 'state': 'approved'},
{'payment_mode': 'company_account', 'state': 'approved'},
])
first_expense_by_employee.action_reset()
self.assertEqual(first_expense_by_employee.state, 'draft')
first_expense_by_employee.unlink()
# Only possible if no expense linked to the account
self.analytic_account_1.unlink()
def test_expense_split_flow(self):
""" Check Split Expense flow. """
# Grant Analytic Accounting rights, to be able to modify analytic_distribution from the wizard
self.env.user.group_ids += self.env.ref('analytic.group_analytic_accounting')
expense = self.create_expenses({
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
'analytic_distribution': {self.analytic_account_1.id: 100}
})
wizard = self.env['hr.expense.split.wizard'].browse(expense.action_split_wizard()['res_id'])
# Check default hr.expense.split values
self.assertRecordValues(wizard.expense_split_line_ids, [
{
'name': expense.name,
'wizard_id': wizard.id,
'expense_id': expense.id,
'product_id': expense.product_id.id,
'tax_ids': expense.tax_ids.ids,
'total_amount_currency': expense.total_amount_currency / 2,
'tax_amount_currency': 65.22,
'employee_id': expense.employee_id.id,
'company_id': expense.company_id.id,
'currency_id': expense.currency_id.id,
'analytic_distribution': expense.analytic_distribution,
}] * 2)
self.assertRecordValues(wizard, [{'split_possible': True, 'total_amount_currency': expense.total_amount_currency}])
with Form(wizard) as form:
form.expense_split_line_ids.remove(index=0)
self.assertEqual(form.split_possible, False)
# Check removing tax_ids and analytic_distribution
with form.expense_split_line_ids.edit(0) as line:
line.total_amount_currency = 200.00
line.tax_ids.clear()
line.analytic_distribution = {}
self.assertEqual(line.total_amount_currency, 200.00)
self.assertEqual(line.tax_amount_currency, 0.00)
self.assertEqual(form.split_possible, False)
# This line should have the same tax_ids and analytic_distribution as original expense
with form.expense_split_line_ids.new() as line:
line.total_amount_currency = 300.00
self.assertEqual(line.total_amount_currency, 300.00)
self.assertEqual(line.tax_amount_currency, 39.13)
self.assertDictEqual(line.analytic_distribution, expense.analytic_distribution)
self.assertEqual(form.split_possible, False)
self.assertEqual(form.total_amount_currency, 500.00)
# Check adding tax_ids and setting analytic_distribution
with form.expense_split_line_ids.new() as line:
line.total_amount_currency = 500.00
line.tax_ids.add(self.tax_purchase_b)
line.analytic_distribution = {self.analytic_account_2.id: 100}
self.assertEqual(line.total_amount_currency, 500.00)
self.assertEqual(line.tax_amount_currency, 115.38)
# Check wizard values
self.assertRecordValues(wizard, [
{'total_amount_currency': 1000.00, 'total_amount_currency_original': 1000.00, 'tax_amount_currency': 154.51, 'split_possible': True}
])
wizard.action_split_expense()
# Check that split resulted into expenses with correct values
expenses_after_split = self.env['hr.expense'].search([('name', '=', expense.name)])
self.assertRecordValues(expenses_after_split.sorted('total_amount_currency'), [
{
'name': expense.name,
'employee_id': expense.employee_id.id,
'product_id': expense.product_id.id,
'total_amount_currency': 200.00,
'tax_ids': [],
'tax_amount_currency': 0.00,
'untaxed_amount_currency': 200.00,
'analytic_distribution': False,
'split_expense_origin_id': expense.id,
}, {
'name': expense.name,
'employee_id': expense.employee_id.id,
'product_id': expense.product_id.id,
'total_amount_currency': 300.00,
'tax_ids': [self.tax_purchase_a.id],
'tax_amount_currency': 39.13,
'untaxed_amount_currency': 260.87,
'analytic_distribution': {str(self.analytic_account_1.id): 100},
'split_expense_origin_id': expense.id,
}, {
'name': expense.name,
'employee_id': expense.employee_id.id,
'product_id': expense.product_id.id,
'total_amount_currency': 500.00,
'tax_ids': [self.tax_purchase_a.id, self.tax_purchase_b.id],
'tax_amount_currency': 115.38,
'untaxed_amount_currency': 384.62,
'analytic_distribution': {str(self.analytic_account_2.id): 100},
'split_expense_origin_id': expense.id,
}
])
#############################################
# Test Multi-currency
#############################################
def test_expense_multi_currencies(self):
"""
Checks that the currency rate is recomputed properly when the total in company currency is set to a new value
"""
foreign_currency_1 = self.other_currency
foreign_currency_2 = self.setup_other_currency('GBP', rounding=0.01, rates=([('2016-01-01', 1 / 1.52)]))
foreign_sale_journal = self.company_data['default_journal_sale'].copy()
foreign_sale_journal.currency_id = foreign_currency_2.id
foreign_expense_1, foreign_expense_2, foreign_expense_3 = self.create_expenses([{
'name': 'foreign_expense_1',
'payment_mode': 'company_account',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount_currency': 1000.00,
'date': self.frozen_today,
'company_id': self.company_data['company'].id,
'currency_id': foreign_currency_1.id, # rate is 1:2
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
},
{
'name': 'foreign_expense_2',
'payment_mode': 'company_account',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount_currency': 1000.00,
'date': self.frozen_today,
'company_id': self.company_data['company'].id,
'currency_id': foreign_currency_2.id, # rate is 1:1.52
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
},
{
'name': 'foreign_expense_3',
'payment_mode': 'company_account',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount_currency': 1000.00,
'total_amount': 3000.00,
'date': self.frozen_today,
'company_id': self.company_data['company'].id,
'currency_id': foreign_currency_2.id, # default rate is 1:1.52, should be overridden to 1:3
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
},
]).sorted('name')
all_expenses = foreign_expense_1 | foreign_expense_2 | foreign_expense_3
self.assertRecordValues(all_expenses, [
{'total_amount': 500.00, 'total_amount_currency': 1000.00, 'currency_rate': 0.50},
{'total_amount': 1520.00, 'total_amount_currency': 1000.00, 'currency_rate': 1.52},
{'total_amount': 3000.00, 'total_amount_currency': 1000.00, 'currency_rate': 3.00},
])
# Manually changing rate on the two first expenses after creation to check they recompute properly
# Back-end override
foreign_expense_1.total_amount = 1000.00
self.assertRecordValues(foreign_expense_1, [
{'total_amount': 1000.00, 'total_amount_currency': 1000.00, 'currency_rate': 1.0},
])
# Front-end override
with Form(foreign_expense_2) as expense_form:
expense_form.total_amount = 2000.00
self.assertRecordValues(foreign_expense_2, [
{'total_amount': 2000.00, 'total_amount_currency': 1000.00, 'currency_rate': 2.0},
])
# Move creation should not touch the rates anymore
all_expenses.action_submit()
all_expenses._do_approve() # Skip duplicate wizard
self.post_expenses_with_wizard(all_expenses, journal=foreign_sale_journal)
self.assertRecordValues(all_expenses.account_move_id.sorted('id'), [
{'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 1000.00, 'currency_id': foreign_currency_1.id},
{'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 2000.00, 'currency_id': foreign_currency_2.id},
{'amount_total_in_currency_signed': 1000.00, 'amount_total_signed': 3000.00, 'currency_id': foreign_currency_2.id},
])
self.assertRecordValues(all_expenses.account_move_id.origin_payment_id.sorted('id'), [
{'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_1.id},
{'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id},
{'amount': 1000.00, 'payment_type': 'outbound', 'currency_id': foreign_currency_2.id},
])
#############################################
# Test Corner Cases
#############################################
def test_expense_company_dates(self):
expenses = self.create_expenses([
{
'name': 'Car Travel Expenses',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount': 350.00,
'payment_mode': 'company_account',
'date': '2024-01-01',
},
{
'name': 'Lunch expense',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount': 90.00,
'payment_mode': 'company_account',
'date': '2024-01-12',
},
]).sorted() # By date desc
expenses.action_submit()
expenses.action_approve()
expenses.action_post()
move_twelve_january, move_first_january = expenses.account_move_id.sorted() # By date desc
self.assertEqual(
move_twelve_january.date,
fields.Date.to_date('2024-01-12'),
'move date should be the same as the expense date'
)
self.assertEqual(
move_first_january.date,
fields.Date.to_date('2024-01-01'),
'move date should be the same as the expense date'
)
self.assertTrue(90 == move_twelve_january.amount_total == move_twelve_january.origin_payment_id.amount)
self.assertTrue(350 == move_first_january.amount_total == move_first_january.origin_payment_id.amount)
self.assertRecordValues(expenses, [
{'date': fields.Date.from_string('2024-01-12'), 'total_amount': 90.00, 'state': 'paid'},
{'date': fields.Date.from_string('2024-01-01'),'total_amount': 350.00, 'state': 'paid'},
])
def test_corner_case_defaults_values_from_product(self):
""" As soon as you set a product, the expense name, uom, taxes and account are set according to the product. """
# Disable multi-uom
self.env.ref('base.group_user').implied_ids -= self.env.ref('uom.group_uom')
self.expense_user_employee.group_ids -= self.env.ref('uom.group_uom')
# Use the expense employee
Expense = self.env['hr.expense'].with_user(self.expense_user_employee)
# Make sure the multi-uom is correctly disabled for the user creating the expense
self.assertFalse(Expense.env.user.has_group('uom.group_uom'))
# Use a product not using the default uom "Unit(s)"
product = Expense.env.ref('hr_expense.expense_product_mileage')
expense_form = Form(Expense)
expense_form.product_id = product
expense = expense_form.save()
self.assertEqual(expense.name, product.display_name)
self.assertEqual(expense.product_uom_id, product.uom_id)
self.assertEqual(expense.tax_ids, product.supplier_taxes_id.filtered(lambda t: t.company_id == expense.company_id))
self.assertEqual(expense.account_id, product._get_product_accounts()['expense'])
def test_attachments_in_move_from_own_expense(self):
""" Checks that journal entries created form expense reports paid by employee have a copy of the attachments in the expense. """
expense = self.create_expenses({'name': 'Employee expense'})
expense_2 = self.create_expenses({'name': 'Employee expense 2'})
attachment = self.env['ir.attachment'].create({
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file1.png',
'res_model': 'hr.expense',
'res_id': expense.id,
})
attachment_2 = self.env['ir.attachment'].create({
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file2.png',
'res_model': 'hr.expense',
'res_id': expense_2.id,
})
expense.message_main_attachment_id = attachment
expense_2.message_main_attachment_id = attachment_2
expenses = expense | expense_2
expenses.action_submit()
expenses._do_approve() # Skip duplicate wizard
self.post_expenses_with_wizard(expenses)
self.assertRecordValues(expenses.account_move_id.attachment_ids.sorted('name'), [
{
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file1.png',
'res_model': 'account.move',
'res_id': expense.account_move_id.id,
},
{
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file2.png',
'res_model': 'account.move',
'res_id': expense_2.account_move_id.id,
}
])
def test_attachments_in_move_from_company_expense(self):
""" Checks that journal entries created form expense reports paid by company have a copy of the attachments in the expense. """
expense = self.create_expenses({
'name': 'Company expense',
'payment_mode': 'company_account',
})
expense_2 = self.create_expenses({
'name': 'Company expense 2',
'payment_mode': 'company_account',
})
attachment = self.env['ir.attachment'].create({
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file1.png',
'res_model': 'hr.expense',
'res_id': expense.id,
})
attachment_2 = self.env['ir.attachment'].create({
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file2.png',
'res_model': 'hr.expense',
'res_id': expense_2.id,
})
expense.message_main_attachment_id = attachment
expense_2.message_main_attachment_id = attachment_2
expenses = expense | expense_2
expenses.action_submit()
expenses._do_approve() # Skip duplicate wizard
expenses.action_post()
expense_move = expense.account_move_id
expense_2_move = expense_2.account_move_id
self.assertRecordValues(expense_move.attachment_ids, [{
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file1.png',
'res_model': 'account.move',
'res_id': expense_move.id
}])
self.assertRecordValues(expense_2_move.attachment_ids, [{
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': 'file2.png',
'res_model': 'account.move',
'res_id': expense_2_move.id
}])
def test_multiple_attachments_in_move_from_company_expense(self):
""" Checks that all attachments from expense are copied to their journal entries. """
expense = self.create_expenses({
'name': 'Company expense',
'payment_mode': 'company_account',
})
self.env['ir.attachment'].create([{
'raw': b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=",
'name': f'file{i}.png',
'res_model': 'hr.expense',
'res_id': expense.id,
} for i in range(1, 3)])
expense.action_submit()
expense._do_approve() # Skip duplicate wizard
expense.action_post()
self.assertEqual(len(expense.account_move_id.attachment_ids), 2)
def test_expense_payment_method(self):
default_payment_method_line = self.company_data['default_journal_bank'].outbound_payment_method_line_ids[0]
check_method = self.env['account.payment.method'].sudo().create({
'name': 'Print checks',
'code': 'check_printing_expense_test',
'payment_type': 'outbound',
})
new_payment_method_line = self.env['account.payment.method.line'].create({
'name': 'Check',
'payment_method_id': check_method.id,
'journal_id': self.company_data['default_journal_bank'].id,
'payment_account_id': self.inbound_payment_method_line.payment_account_id.id,
})
expense = self.create_expenses({
'payment_method_line_id': default_payment_method_line.id,
'payment_mode': 'company_account',
})
self.assertRecordValues(expense, [{'payment_method_line_id': default_payment_method_line.id}])
expense.payment_method_line_id = new_payment_method_line
expense.action_submit()
expense.action_approve()
expense.action_post()
self.assertRecordValues(expense.account_move_id.origin_payment_id, [{'payment_method_line_id': new_payment_method_line.id}])
@freeze_time('2024-01-01')
def test_expense_vendor(self):
""" This test will do a basic flow when a vendor is set on the expense """
vendor_a = self.env['res.partner'].create({'name': 'Ruben'})
expense = self.create_expenses({
'payment_mode': 'company_account',
'vendor_id': vendor_a.id,
})
expense.action_submit()
expense.action_approve()
expense.action_post()
self.assertEqual(vendor_a.id, expense.account_move_id.line_ids.partner_id.id)
def test_payment_edit_fields(self):
""" Test that some payment fields cannot be modified once linked with an expense """
expense = self.create_expenses({
'payment_mode': 'company_account',
'total_amount_currency': 1000.00,
})
expense.action_submit()
expense.action_approve()
expense.action_post()
payment = expense.account_move_id.origin_payment_id
with self.assertRaises(UserError, msg="Cannot edit payment amount after linking to an expense"):
payment.write({'amount': 500})
payment.write({'is_sent': True})
def test_corner_case_expense_submitted_cannot_be_zero(self):
"""
Test that the expenses are not submitted if the total amount is 0.0 nor able to be edited that way
unless unlinking it from the expense.
"""
expense = self.create_expenses({'total_amount': 0.0, 'total_amount_currency': 0.0})
# CASE 1: FORBIDS Trying to submit an expense with a total_amount(_currency) of 0.0
with self.assertRaises(UserError):
expense.action_submit()
# CASE 2: FORBIDS Trying to change the total_amount(_currency) to 0.0 when the expense is submitted to the manager
expense.total_amount_currency = 1000
expense.action_submit()
with self.assertRaises(UserError):
expense.total_amount_currency = 0.0
with self.assertRaises(UserError):
expense.total_amount = 0.0
# CASE 3: FORBIDS Trying to change the total_amount(_currency) to 0.0 when the expense is approved
expense.action_approve()
with self.assertRaises(UserError):
expense.total_amount_currency = 0.0
with self.assertRaises(UserError):
expense.total_amount = 0.0
# CASE 4: FORBIDS Trying to change the total_amount(_currency) to 0.0 when the expense is posted and the account move created
self.post_expenses_with_wizard(expense)
with self.assertRaises(UserError):
expense.total_amount_currency = 0.0
with self.assertRaises(UserError):
expense.total_amount = 0.0
# CASE 5: ALLOWS Changing the total_amount(_currency) to 0.0 when the expense is reset to draft
expense.account_move_id.button_draft()
expense.account_move_id.unlink()
expense.action_reset()
expense.write({'total_amount_currency': 0.0, 'total_amount': 0.0})
# CASE 6: FORBIDS Setting the amounts to 0 while submitting the expense
expense.write({'total_amount_currency': 1000.0, 'total_amount': 1000.0})
with self.assertRaises(UserError):
expense.write({'total_amount_currency': 0.0, 'state': 'submitted'})
with self.assertRaises(UserError):
expense.write({'total_amount': 0.0, 'state': 'submitted'})
# CASE 7: ALLOWS Setting the amounts to 0 while resetting the expense to draft
expense.write({'total_amount_currency': 0.0, 'total_amount': 0.0, 'state': 'draft'})
def test_foreign_currencies_total(self):
""" Check that the dashboard computes amount properly in company currency """
self.create_expenses([{
'name': 'Company expense',
'payment_mode': 'company_account',
'total_amount_currency': 1000.00,
'employee_id': self.expense_employee.id,
},
{
'name': 'Employee expense',
'payment_mode': 'own_account',
'currency_id': self.other_currency.id,
'total_amount_currency': 1000.00,
'total_amount': 2000.00,
'employee_id': self.expense_employee.id,
},
])
expense_data = self.env['hr.expense'].with_user(self.expense_user_employee).get_expense_dashboard()
self.assertEqual(expense_data['draft']['amount'], 3000.00)
def test_update_expense_price_on_product_standard_price(self):
"""
Tests that updating the standard price of a product will update all the un-submitted
expenses using that product as a category.
"""
product = self.env['product.product'].create({
'name': 'Product',
'standard_price': 100.0,
})
expenses = expense_no_update, expense_update = self.create_expenses([
{'name': name, 'product_id': product.id, 'total_amount': 100.0}
for name in ('test no update', 'test update')
]).sorted('name')
self.assertRecordValues(expenses.sorted('name'), [
{'name': 'test no update', 'price_unit': 100.0, 'quantity': 1, 'total_amount': 100.0},
{'name': 'test update', 'price_unit': 100.0, 'quantity': 1, 'total_amount': 100.0},
])
expense_no_update.action_submit() # No update when the expense is submitted
product.standard_price = 200.0
self.assertRecordValues(expenses.sorted('name'), [
{'name': 'test no update', 'price_unit': 100.0, 'quantity': 1.0, 'total_amount': 100.0},
{'name': 'test update', 'price_unit': 200.0, 'quantity': 1.0, 'total_amount': 200.0}, # total is updated
])
expense_update.quantity = 5
self.assertRecordValues(expenses.sorted('name'), [
{'name': 'test no update', 'price_unit': 100.0, 'quantity': 1, 'total_amount': 100.0},
{'name': 'test update', 'price_unit': 200.0, 'quantity': 5, 'total_amount': 1000.0}, # total is updated
])
product.standard_price = 0.0
self.assertRecordValues(expenses.sorted('name'), [
{'name': 'test no update', 'price_unit': 100.0, 'quantity': 1, 'total_amount': 100.0},
{'name': 'test update', 'price_unit': 1000.0, 'quantity': 1, 'total_amount': 1000.0}, # quantity & price_unit only are updated
])
expenses.action_submit() # This expense should not be updated any more
product.standard_price = 300.0
self.assertRecordValues(expenses.sorted('name'), [
{'name': 'test no update', 'price_unit': 100.0, 'quantity': 1, 'total_amount': 100.0},
{'name': 'test update', 'price_unit': 1000.0, 'quantity': 1, 'total_amount': 1000.0}, # no update
])
def test_expense_standard_price_update_warning(self):
expense_cat_A = self.env['product.product'].create({
'name': 'Category A',
'default_code': 'CA',
'standard_price': 0.0,
})
expense_cat_B = self.env['product.product'].create({
'name': 'Category B',
'default_code': 'CB',
'standard_price': 0.0,
})
expense_cat_C = self.env['product.product'].create({
'name': 'Category C',
'default_code': 'CC',
'standard_price': 0.0,
})
self.create_expenses([
{
'name': 'Expense 1',
'product_id': expense_cat_A.id,
'total_amount': 1,
},
{
'name': 'Expense 2',
'product_id': expense_cat_B.id,
'total_amount': 5,
},
])
# At first, there is no warning message on the categories because their prices are 0
self.assertFalse(expense_cat_A.standard_price_update_warning)
self.assertFalse(expense_cat_B.standard_price_update_warning)
self.assertFalse(expense_cat_C.standard_price_update_warning)
# When modifying the price of the first category, a message should appear as a an expense will be modified.
with Form(expense_cat_A, view="hr_expense.product_product_expense_form_view") as form:
form.standard_price = 5
self.assertTrue(form.standard_price_update_warning)
# When modifying the price of the second category, no message should appear as the price of the linked
# expense is the price of the category that is going to be saved.
with Form(expense_cat_B, view="hr_expense.product_product_expense_form_view") as form:
form.standard_price = 5
self.assertFalse(form.standard_price_update_warning)
# When modifying the price of the their category, no message should appear as no expense is linked to it.
with Form(expense_cat_C, view="hr_expense.product_product_expense_form_view") as form:
form.standard_price = 5
self.assertFalse(form.standard_price_update_warning)
def test_compute_standard_price_update_warning_product_with_and_without_expense(self):
"""
Test that the compute doesn't raise an error with mixed recordsets (products used in expenses and not used in expenses)
"""
product_expensed = self.env['product.product'].create({
'name': 'Category A',
'default_code': 'CA',
'standard_price': 0.0,
})
product_not_expensed = self.env['product.product'].create({
'name': 'Category B',
'default_code': 'CB',
'standard_price': 0.0,
})
self.env['hr.expense'].create({
'employee_id': self.expense_employee.id,
'name': 'Expense 1',
'product_id': product_expensed.id,
'total_amount': 1,
})
(product_expensed | product_not_expensed)._compute_standard_price_update_warning()
def test_expense_multi_company(self):
main_company = self.company_data['company']
other_company = self.company_data_2['company']
self.expense_employee.sudo().company_id = other_company
# The expense employee is able to create an expense for company_2.
# product_a needs a standard_price in company_2
self.product_a.with_context(allowed_company_ids=self.company_data_2['company'].ids).standard_price = 100
Expense = self.env['hr.expense'].with_user(self.expense_user_employee).with_context(allowed_company_ids=other_company.ids)
expense_approve = Expense.create([{
'name': 'First Expense for employee',
'employee_id': self.expense_employee.id,
'date': '2016-01-01',
'product_id': self.product_a.id,
'quantity': 1200.0,
}])
expense_refuse = Expense.create([{
'name': 'Second Expense for employee',
'employee_id': self.expense_employee.id,
'date': '2016-01-01',
'product_id': self.product_a.id,
'quantity': 1000.0,
}])
expenses = expense_approve | expense_refuse
self.assertRecordValues(expenses, [
{'company_id': self.company_data_2['company'].id},
{'company_id': self.company_data_2['company'].id},
])
# The expense employee is able to submit the expense.
expenses.with_user(self.expense_user_employee).action_submit()
# An expense manager is not able to approve nor refuse without access to company_2.
with self.assertRaises(UserError):
expense_approve \
.with_user(self.expense_user_manager) \
.with_context(allowed_company_ids=main_company.ids, company_id=main_company.id) \
.action_approve()
with self.assertRaises(UserError):
expense_refuse \
.with_user(self.expense_user_manager) \
.with_context(allowed_company_ids=main_company.ids) \
._do_refuse('failed')
# An expense manager is able to approve/refuse with access to company_2.
expense_approve \
.with_user(self.expense_user_manager) \
.with_context(allowed_company_ids=other_company.ids) \
.action_approve()
expense_refuse \
.with_user(self.expense_user_manager) \
.with_context(allowed_company_ids=other_company.ids) \
._do_refuse('failed')
# An expense manager having accounting access rights is not able to post the journal entry without access
# to company_2.
with self.assertRaises(UserError):
self.post_expenses_with_wizard(expense_approve.with_user(self.env.user).with_context(allowed_company_ids=main_company.ids))
# An expense manager having accounting access rights is able to post the journal entry with access to
# company_2.
self.post_expenses_with_wizard(expense_approve.with_user(self.env.user).with_context(allowed_company_ids=other_company.ids))
def test_tax_is_used_when_in_transactions(self):
""" Ensures that a tax is set to used when it is part of some transactions """
# Account.move is one type of transaction
tax_expense = self.env['account.tax'].create({
'name': 'test_is_used_expenses',
'amount': '100',
'include_base_amount': True,
})
self.create_expenses({'tax_ids': [Command.set(tax_expense.ids)]})
tax_expense.invalidate_model(fnames=['is_used'])
self.assertTrue(tax_expense.is_used)
def test_expense_by_company_with_caba_tax(self):
""" When using cash basis tax in an expense paid by the company, the transition account should not be used. """
caba_tag = self.env['account.account.tag'].create({
'name': 'Cash Basis Tag Final Account',
'applicability': 'taxes',
})
caba_transition_account = self.env['account.account'].create({
'name': 'Cash Basis Tax Transition Account',
'account_type': 'asset_current',
'code': '131001',
'reconcile': True,
})
caba_tax = self.env['account.tax'].create({
'name': 'Cash Basis Tax',
'tax_exigibility': 'on_payment',
'amount': 15,
'cash_basis_transition_account_id': caba_transition_account.id,
'invoice_repartition_line_ids': [
Command.create({
'factor_percent': 100,
'repartition_type': 'base',
}),
Command.create({
'factor_percent': 100,
'repartition_type': 'tax',
'tag_ids': caba_tag.ids,
}),
]
})
expense = self.create_expenses({
'payment_mode': 'company_account',
'tax_ids': [Command.set(caba_tax.ids)],
})
expense.action_submit()
expense.action_approve()
expense.action_post()
moves = expense.account_move_id
tax_lines = moves.line_ids.filtered(lambda line: line.tax_line_id == caba_tax)
self.assertNotEqual(tax_lines.account_id, caba_transition_account, "The tax should not be on the transition account")
self.assertEqual(tax_lines.tax_tag_ids, caba_tag, "The tax should still retrieve its tags")
def test_expense_mandatory_analytic_plan_product_category(self):
"""
Check that when an analytic plan has a mandatory applicability matching
product category this is correctly triggered
"""
self.env['account.analytic.applicability'].create({
'business_domain': 'expense',
'analytic_plan_id': self.analytic_plan.id,
'applicability': 'mandatory',
'product_categ_id': self.product_a.categ_id.id,
})
expense = self.create_expenses({
'product_id': self.product_a.id,
'quantity': 350.00,
'payment_mode': 'company_account',
})
expense.action_submit()
with self.assertRaises(ValidationError, msg="One or more lines require a 100% analytic distribution."):
expense.with_context(validate_analytic=True).action_approve()
expense.analytic_distribution = {self.analytic_account_1.id: 100.00}
expense.with_context(validate_analytic=True).action_approve()
def test_expense_no_stealing_from_employees(self):
"""
Test to check that the company doesn't steal their employee when the commercial_partner_id of the employee partner
is the company
"""
self.expense_employee.user_partner_id.parent_id = self.env.company.partner_id
self.assertEqual(self.env.company.partner_id, self.expense_employee.user_partner_id.commercial_partner_id)
expense = self.create_expenses({'employee_id': self.expense_employee.id})
expense.action_submit()
expense.action_approve()
self.post_expenses_with_wizard(expense)
move = expense.account_move_id
self.assertNotEqual(move.commercial_partner_id, self.env.company.partner_id)
self.assertEqual(move.partner_id, self.expense_employee.user_partner_id)
self.assertEqual(move.commercial_partner_id, self.expense_employee.user_partner_id)
def test_expense_set_total_amount_to_0(self):
""" Checks that amount fields are correctly updating when setting total_amount to 0 """
expense = self.create_expenses({
'product_id': self.product_c.id,
'total_amount_currency': 100.0,
})
expense.total_amount_currency = 0.0
self.assertTrue(expense.currency_id.is_zero(expense.tax_amount))
self.assertTrue(expense.company_currency_id.is_zero(expense.total_amount))
def test_expense_set_quantity_to_0(self):
""" Checks that amount fields except for unit_amount are correctly updating when setting quantity to 0 """
expense = self.create_expenses({
'product_id': self.product_b.id,
'quantity': 10
})
expense.quantity = 0
self.assertTrue(expense.currency_id.is_zero(expense.total_amount_currency))
self.assertEqual(expense.company_currency_id.compare_amounts(expense.price_unit, self.product_b.standard_price), 0)
def test_employee_expense_in_foreign_currency(self):
""" Checks that the currency of the posted entries is always the company currency """
expense = self.create_expenses({
'payment_mode': 'own_account',
'currency_id': self.other_currency.id,
})
expense.action_submit()
expense.action_approve()
expense._post_without_wizard()
self.assertRecordValues(
expense.account_move_id,
[{'amount_total': 500.0, 'currency_id': expense.account_move_id.company_currency_id.id}],
)
def test_company_expense_sepa_ct_trust_bypass(self):
"""
Ensure company-paid expenses using SEPA CT post without requiring a trusted recipient bank account.
This validates the bypass in account.payment.action_post for expense-originated payments.
"""
self.env.ref('base.EUR').active = True
bank_journal = self.company_data['default_journal_bank']
bank = self.env['res.bank'].create({
'name': 'BNP Paribas',
'bic': 'GEBABEBB',
})
bank_journal.write({
'bank_id': bank.id,
'bank_acc_number': 'BE48363523682327',
'currency_id': self.env.ref('base.EUR').id,
})
sepa_ct_line = bank_journal.outbound_payment_method_line_ids.filtered(lambda l: l.code == 'sepa_ct')
if not sepa_ct_line:
self.skipTest("SEPA Credit Transfer payment method not available (account_sepa module not installed)")
expense = self.create_expenses({
'name': 'Hotel',
'payment_mode': 'company_account',
'payment_method_line_id': sepa_ct_line.id,
'total_amount_currency': 100.00,
'currency_id': self.env.ref('base.EUR').id,
})
expense.action_submit()
expense.action_approve()
# Should not raise trust validation error despite missing recipient partner bank
expense.action_post()
def test_expense_analytic_vendor_bill_count(self):
"""
Verify that purchase receipts appear when opening a vendor bill via the smart button,
and that the vendor bill count matches the records shown.
"""
expense = self.create_expenses([
{
'name': 'Employee PA 2*800 + 15%',
'analytic_distribution': {self.analytic_account_1.id: 100},
}])
expense.action_submit()
expense.action_approve()
self.post_expenses_with_wizard(expense)
self.assertEqual('in_receipt', expense.account_move_id.move_type)
vendor_bill_view = self.analytic_account_1.action_view_vendor_bill()
self.assertTrue(expense.account_move_id.id in vendor_bill_view['domain'][0][2])
def test_expense_paid_company_no_autobalancing_line(self):
"""
Test that when creating the move associated with an expense paid by company, no autobalancing line
appears when an analytic is added to a move line.
"""
expense = self.create_expenses({
'name': 'Expense for John Smith',
'employee_id': self.expense_employee.id,
'total_amount_currency': 100.0,
'product_id': self.product_c.id,
'payment_mode': 'company_account',
'company_id': self.company_data['company'].id,
'tax_ids': [self.tax_sale_a.id],
})
expense.action_submit()
expense.action_approve()
expense.analytic_distribution = {self.analytic_account_1.id: 100.00}
expense.action_post()
# Check that there is no fourth autobalancing line on the account move
self.assertEqual(expense.account_move_id.line_ids.mapped('balance'), [86.96, 13.04, -100.0])
def test_remove_company_id_from_hr_expense(self):
expense = self.create_expenses({
'name': 'Company PC 1000 + 15%',
'employee_id': self.expense_employee.id,
'product_id': self.product_c.id,
'total_amount_currency': 1000.00,
'date': '2021-10-12',
'payment_mode': 'company_account',
'company_id': self.company_data['company'].id,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
form = Form(expense)
form.company_id = self.env['res.company']
self.assertEqual(form.is_editable, False)
form.company_id = self.env.company
self.assertEqual(form.is_editable, True)