19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:07 +01:00
parent ba20ce7443
commit 768b70e05e
2357 changed files with 1057103 additions and 712486 deletions

View file

@ -1,45 +1,69 @@
# -*- coding: utf-8 -*-
from . import test_account_move_reconcile
from . import test_account_move_payments_widget
from . import test_account_move_out_invoice
from . import test_account_move_out_refund
from . import test_account_move_in_invoice
from . import test_account_move_in_refund
from . import test_account_move_duplicate
from . import test_account_move_entry
from . import test_account_move_date_algorithm
from . import test_invoice_tax_totals
from . import test_account_inalterable_hash
from . import test_account_journal
from . import test_account_account
from . import test_account_tax
from . import test_account_to_check
from . import test_account_analytic
from . import test_account_payment
from . import test_account_payment_method_line
from . import test_account_payment_duplicate
from . import test_account_bank_statement
from . import test_account_move_partner_count
from . import test_account_move_rounding
from . import test_account_invoice_report
from . import test_account_move_line_tax_details
from . import test_account_journal_dashboard_common
from . import test_account_journal_dashboard
from . import test_account_lock_exception
from . import test_audit_trail
from . import test_chart_template
from . import test_company_branch
from . import test_digest
from . import test_download_docs
from . import test_fiscal_position
from . import test_kpi_provider
from . import test_sequence_mixin
from . import test_settings
from . import test_tax
from . import test_taxes_base_lines_tax_details
from . import test_taxes_computation
from . import test_taxes_tax_totals_summary
from . import test_taxes_global_discount
from . import test_taxes_downpayment
from . import test_taxes_dispatching_base_lines
from . import test_invoice_taxes
from . import test_templates_consistency
from . import test_account_move_send
from . import test_account_all_l10n
from . import test_reconciliation_matching_rules
from . import test_account_onboarding
from . import test_portal_attachment
from . import test_product
from . import test_portal_invoice
from . import test_tax_report
from . import test_transfer_wizard
from . import test_account_incoming_supplier_invoice
from . import test_payment_term
from . import test_account_payment_items
from . import test_account_payment_register
from . import test_account_report
from . import test_account_section_and_subsection
from . import test_tour
from . import test_early_payment_discount
from . import test_ir_actions_report
from . import test_download_xsds
from . import test_multivat
from . import test_account_partner
from . import test_setup_wizard
from . import test_structured_reference
from . import test_product
from . import test_unexpected_invoice
from . import test_mail_tracking_value
from . import test_res_partner_merge
from . import test_account_merge_wizard
from . import test_account_move_attachment
from . import test_account_bill_deductibility
from . import test_dict_to_xml
from . import test_duplicate_res_partner_bank
from . import test_account_move_import_template

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,226 @@
# -*- coding: utf-8 -*-
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo.addons.account.tests.common import TestAccountMergeCommon
from odoo.tests import Form, tagged, new_test_user
from odoo.exceptions import UserError, ValidationError
from odoo.tools import mute_logger
import psycopg2
from freezegun import freeze_time
@tagged('post_install', '-at_install')
class TestAccountAccount(AccountTestInvoicingCommon):
class TestAccountAccount(TestAccountMergeCommon):
def test_changing_account_company(self):
''' Ensure you can't change the company of an account.account if there are some journal entries '''
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_data_2 = cls.setup_other_company()
cls.other_currency = cls.setup_other_currency('EUR')
def test_shared_accounts(self):
''' Test that creating an account with a given company in company_ids sets the code for that company.
Test that copying an account creates a new account with the code set for the new account's company_ids company,
and that copying an account that belongs to multiple companies works, even if the copied account had
check_company fields that had values belonging to several companies.
'''
company_1 = self.company_data['company']
company_2 = self.company_data_2['company']
company_3 = self.setup_other_company(name='company_3')['company']
# Test specifying company_ids in account creation.
account = self.env['account.account'].create({
'code': '180001',
'name': 'My Account in Company 2',
'company_ids': [Command.link(company_2.id)]
})
self.assertRecordValues(
account.with_company(company_2),
[{'code': '180001', 'company_ids': company_2.ids}]
)
# Test that adding a company to an account fails if the code is not defined for that account and that company.
with self.assertRaises(ValidationError):
account.write({'company_ids': [Command.link(company_1.id)]})
# Test that you can add a company to an account if you add the code at the same time
account.write({
'code': '180011',
'company_ids': [Command.link(company_1.id)],
'tax_ids': [Command.link(self.company_data['default_tax_sale'].id), Command.link(self.company_data_2['default_tax_sale'].id)],
})
self.assertRecordValues(account, [{'code': '180011', 'company_ids': [company_1.id, company_2.id]}])
# Test that you can create an account with multiple codes and companies if you specify the codes in `code_mapping_ids`
account_2 = self.env['account.account'].create({
'code_mapping_ids': [
Command.create({'company_id': company_1.id, 'code': '180021'}),
Command.create({'company_id': company_2.id, 'code': '180022'}),
Command.create({'company_id': company_3.id, 'code': '180023'}),
],
'name': 'My second account',
'company_ids': [Command.set([company_1.id, company_2.id, company_3.id])],
'tax_ids': [Command.set([self.company_data_2['default_tax_sale'].id])],
})
self.assertRecordValues(account_2, [{'code': '180021', 'company_ids': [company_1.id, company_2.id, company_3.id]}])
self.assertRecordValues(account_2.with_company(company_2), [{'code': '180022'}])
self.assertRecordValues(account_2.with_company(company_3), [{'code': '180023'}])
# Test copying an account belonging to multiple companies, specifying the company the new account should belong to.
account_copy_1 = account.copy({'company_ids': [Command.set(company_1.ids)], 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)]})
self.assertRecordValues(
account_copy_1,
[{'code': '180012', 'company_ids': company_1.ids, 'tax_ids': self.company_data['default_tax_sale'].ids}],
)
# Test copying an account belonging to multiple companies, without specifying the company. Both companies should be copied.
account_copy_2 = account.copy()
self.assertRecordValues(
account_copy_2,
[{
'code': '180013',
'company_ids': [company_1.id, company_2.id],
'tax_ids': [self.company_data['default_tax_sale'].id, self.company_data_2['default_tax_sale'].id],
}],
)
self.assertRecordValues(account_copy_2.with_company(company_2), [{'code': '180002'}])
# Test copying an account belonging to 3 companies
account_copy_3 = account_2.copy()
self.assertRecordValues(account_copy_3, [{'code': '180022', 'company_ids': [company_1.id, company_2.id, company_3.id]}])
self.assertRecordValues(account_copy_3.with_company(company_2), [{'code': '180023'}])
self.assertRecordValues(account_copy_3.with_company(company_3), [{'code': '180024'}])
# Test that you can modify the code of an account in another company by writing on `code_mapping_ids`.
account_copy_3.code_mapping_ids[2].code = '180025'
self.assertRecordValues(account_copy_3.with_company(company_3), [{'code': '180025'}])
# Test that you can modify the code of an account in another company by passing a CREATE value to `code_mapping_ids` (needed for import).
account_copy_3.write({'code_mapping_ids': [Command.create({'company_id': company_3.id, 'code': '180026'})]})
self.assertRecordValues(account_copy_3.with_company(company_3), [{'code': '180026'}])
def test_write_on_code_from_branch(self):
""" Ensure that when writing on account.code from a company, the old code isn't erroneously kept
on other companies that share the same root_id """
branch = self.env['res.company'].create([{
'name': "My Test Branch",
'parent_id': self.company_data['company'].id,
}])
account = self.env['account.account'].create([{
'name': 'My Test Account',
'code': '180001',
}])
# Change the code from the branch
account.with_company(branch).code = '180002'
# Ensure it's changed from the perspective of the root company
self.assertRecordValues(account, [{'code': '180002'}])
def test_ensure_code_unique(self):
''' Test the `_ensure_code_unique` check method.
Check that it allows a code to be set on an account if and only if
there is no other account accessible from the child or parent companies of the account
that has the same code in that company.
Simultaneously, check that the `_search_new_account_code` method proposes codes that
would be accepted and skips codes that would be disallowed.
'''
# Create company hierarchy:
# parent_company -> {child_company_1, child_company_2}
# other_company is disjoint.
parent_company = self.company_data['company']
child_company_1 = self.env['res.company'].create([{
'name': 'Child Company 1',
'parent_id': parent_company.id,
}])
child_company_2 = self.env['res.company'].create([{
'name': 'Child Company 2',
'parent_id': parent_company.id,
}])
other_company = self.company_data_2['company']
# Set up an existing account in the other company.
self.env['account.account'].with_context({'allowed_company_ids': other_company.ids}).create([{
'name': 'Existing account in other company',
'company_ids': [Command.set(other_company.ids)],
'code_mapping_ids': [
Command.create({'company_id': other_company.id, 'code': '180001'}),
Command.create({'company_id': parent_company.id, 'code': '180001'}),
]
}])
# 1. Check that the existing account in the other company does not prevent
# an account from being created with the same code in `parent_company`.
self.assertEqual(
self.env['account.account'].with_company(parent_company)._search_new_account_code('180001', cache=set()),
'180001',
)
self.env['account.account'].create([{
'name': 'Account in parent company',
'code': '180001',
'company_ids': [Command.set(parent_company.ids)],
}])
# 2. Check that now that there is an account in `parent_company` with code
# 180001, because that account is accessible in `child_company_1`, we cannot
# create another account with the same code in `child_company_1`.
self.assertEqual(
self.env['account.account'].with_company(child_company_1)._search_new_account_code('180001', cache=set()),
'180002',
)
with self.assertRaises(ValidationError):
self.env['account.account'].create([{
'name': 'Account in child company 1 (this should fail)',
'code': '180001',
'company_ids': [Command.set(child_company_1.ids)],
}])
# Now, create an account in `child_company_1` with a new code.
self.env['account.account'].create([{
'name': 'Account in child company 1',
'code': '180002',
'company_ids': [Command.set(child_company_1.ids)],
}])
# 3. Check that now that there is an account in `child_company_1` with code
# 180002, because any new accounts in `parent_company` are also accessible
# from `child_company_1`, we cannot create another account with the same
# code in `parent_company`.
self.assertEqual(
self.env['account.account'].with_company(parent_company)._search_new_account_code('180002', cache=set()),
'180003',
)
with self.assertRaises(ValidationError):
self.env['account.account'].create([{
'name': 'Account in parent company (this should fail)',
'code': '180002',
'company_ids': [Command.set(child_company_1.ids)],
}])
# 4. Check that we can create an account in `child_company_2` with code
# 180002, because it will not interfere with the account in `child_company_1`
# with code 180002.
self.assertEqual(
self.env['account.account'].with_company(child_company_2)._search_new_account_code('180002', cache=set()),
'180002',
)
self.env['account.account'].create([{
'name': 'Account in child company 2',
'code': '180002',
'company_ids': [Command.set(child_company_2.ids)],
}])
def test_account_company(self):
''' Test the constraint on `account.company_ids`. '''
# Test that at least one company is required on accounts.
with self.assertRaises(UserError):
self.company_data['default_account_revenue'].sudo().company_ids = False
# Test that unassigning a company from an account fails if there already are journal items
# for that company and that account.
self.env['account.move'].create({
'move_type': 'entry',
'date': '2019-01-01',
@ -30,8 +236,8 @@ class TestAccountAccount(AccountTestInvoicingCommon):
],
})
with self.assertRaises(UserError), self.cr.savepoint():
self.company_data['default_account_revenue'].company_id = self.company_data_2['company']
with self.assertRaises(UserError):
self.company_data['default_account_revenue'].company_ids = self.company_data_2['company']
def test_toggle_reconcile(self):
''' Test the feature when the user sets an account as reconcile/not reconcile with existing journal entries. '''
@ -43,14 +249,14 @@ class TestAccountAccount(AccountTestInvoicingCommon):
'line_ids': [
(0, 0, {
'account_id': account.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'debit': 100.0,
'credit': 0.0,
'amount_currency': 200.0,
}),
(0, 0, {
'account_id': account.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'debit': 0.0,
'credit': 100.0,
'amount_currency': -200.0,
@ -100,21 +306,21 @@ class TestAccountAccount(AccountTestInvoicingCommon):
'line_ids': [
(0, 0, {
'account_id': account.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'debit': 100.0,
'credit': 0.0,
'amount_currency': 200.0,
}),
(0, 0, {
'account_id': account.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'debit': 0.0,
'credit': 50.0,
'amount_currency': -100.0,
}),
(0, 0, {
'account_id': self.company_data['default_account_expense'].id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'debit': 0.0,
'credit': 50.0,
'amount_currency': -100.0,
@ -125,23 +331,13 @@ class TestAccountAccount(AccountTestInvoicingCommon):
# Set the account as reconcile and partially reconcile something.
account.reconcile = True
self.env.invalidate_all()
move.line_ids.filtered(lambda line: line.account_id == account).reconcile()
# Try to set the account as a not-reconcile one.
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaises(UserError):
account.reconcile = False
def test_toggle_reconcile_outstanding_account(self):
''' Test the feature when the user sets an account as not reconcilable when a journal
is configured with this account as the payment credit or debit account.
Since such an account should be reconcilable by nature, a ValidationError is raised.'''
with self.assertRaises(ValidationError), self.cr.savepoint():
self.company_data['default_journal_bank'].company_id.account_journal_payment_debit_account_id.reconcile = False
with self.assertRaises(ValidationError), self.cr.savepoint():
self.company_data['default_journal_bank'].company_id.account_journal_payment_credit_account_id.reconcile = False
def test_remove_account_from_account_group(self):
"""Test if an account is well removed from account group"""
group = self.env['account.group'].create({
@ -158,6 +354,9 @@ class TestAccountAccount(AccountTestInvoicingCommon):
group.code_prefix_end = 401000
# Because group_id must depend on the group start and end, but there is no way of making this dependency explicit.
(account_1 | account_2).invalidate_recordset(fnames=['group_id'])
self.assertRecordValues(account_1 + account_2, [{'group_id': group.id}, {'group_id': False}])
def test_name_create(self):
@ -177,24 +376,62 @@ class TestAccountAccount(AccountTestInvoicingCommon):
def test_compute_account_type(self):
existing_account = self.company_data['default_account_revenue']
# account_type should be computed
new_account_code = self.env['account.account']._search_new_account_code(
company=existing_account.company_id,
digits=len(existing_account.code),
prefix=existing_account.code[:-1])
new_account_code = self.env['account.account']._search_new_account_code(existing_account.code)
new_account = self.env['account.account'].create({
'code': new_account_code,
'name': 'A new account'
})
self.assertEqual(new_account.account_type, existing_account.account_type)
# account_type should not be altered
alternate_account = self.env['account.account'].search([('account_type', '!=', existing_account.account_type)], limit=1)
alternate_code = self.env['account.account']._search_new_account_code(
company=alternate_account.company_id,
digits=len(alternate_account.code),
prefix=alternate_account.code[:-1])
alternate_account = self.env['account.account'].search([
('account_type', '!=', existing_account.account_type),
('company_ids', '=', self.company_data['company'].id),
], limit=1)
alternate_code = self.env['account.account']._search_new_account_code(alternate_account.code)
new_account.code = alternate_code
self.assertEqual(new_account.account_type, existing_account.account_type)
def test_get_closest_parent_account(self):
self.env['account.account'].create({
'code': 99998,
'name': 'This name will be transferred to the child one',
'account_type': 'expense',
'tag_ids': [
Command.create({'name': 'Test tag'}),
],
})
account_to_process = self.env['account.account'].create({
'code': 99999,
'name': 'This name will be erase',
})
# The account type and tags will be transferred automatically with the computes
self.assertEqual(account_to_process.account_type, 'expense')
self.assertEqual(account_to_process.tag_ids.name, 'Test tag')
def test_search_new_account_code(self):
""" Test whether the account codes tested for availability are the ones we expect. """
# pylint: disable=bad-whitespace
tests = [
# start_code Expected tested codes
('102100', ['102101', '102102', '102103', '102104']),
('1598', ['1599', '1600', '1601', '1602']),
('10.01.08', ['10.01.09', '10.01.10', '10.01.11', '10.01.12']),
('10.01.97', ['10.01.98', '10.01.99', '10.01.97.copy', '10.01.97.copy2']),
('1021A', ['1022A', '1023A', '1024A', '1025A']),
('hello', ['hello.copy', 'hello.copy2', 'hello.copy3', 'hello.copy4']),
('9998', ['9999', '9998.copy', '9998.copy2', '9998.copy3']),
]
for start_code, expected_tested_codes in tests:
start_account = self.env['account.account'].create({
'code': start_code,
'name': 'Test',
'account_type': 'asset_receivable',
})
tested_codes = [start_account.copy().code for _ in expected_tested_codes]
self.assertListEqual(tested_codes, expected_tested_codes)
def test_compute_current_balance(self):
""" Test if an account's current_balance is computed correctly """
@ -278,7 +515,7 @@ class TestAccountAccount(AccountTestInvoicingCommon):
self.assertEqual(account.name, "A new account")
# name split is only possible through name_create, so an error should be raised
with self.assertRaises(psycopg2.DatabaseError), mute_logger('odoo.sql_db'):
with self.assertRaises(ValidationError):
account = self.env['account.account'].create({
'name': '314159 A new account',
'account_type': 'expense',
@ -297,7 +534,11 @@ class TestAccountAccount(AccountTestInvoicingCommon):
"""
Test various scenarios when creating an account via a form
"""
account_form = Form(self.env['account.account'])
# We set `allowed_company_ids` here so that the `with_company(other_company)` in
# `account.code.mapping._inverse_code` creates a context with both the first active
# company and the other company, rather than with just the other company.
# In a client-side form, 'allowed_company_ids' will always be set in the context.
account_form = Form(self.env['account.account'].with_context({'allowed_company_ids': self.env.company.ids}))
account_form.name = "A New Account 1"
# code should not be set
@ -342,7 +583,7 @@ class TestAccountAccount(AccountTestInvoicingCommon):
account_form.code = False
account_form.name = "Only letters"
# saving a form without a code should not be possible
with self.assertRaises(AssertionError):
with self.assertRaises(ValidationError):
account_form.save()
@freeze_time('2023-09-30')
@ -352,7 +593,7 @@ class TestAccountAccount(AccountTestInvoicingCommon):
- Creates: partner and a account move of that partner.
- Checks if the most frequent account for the partner matches created account (with recent move).
- Sets the account as deprecated and checks that it no longer appears in the suggestions.
- Sets the account as archived and checks that it no longer appears in the suggestions.
* since tested function takes into account last 2 years, we use freeze_time
"""
@ -372,16 +613,78 @@ class TestAccountAccount(AccountTestInvoicingCommon):
)
self.assertEqual(account.id, results_1[0], "Account with most account_moves should be listed first")
account.deprecated = True
account.flush_recordset(['deprecated'])
account.active = False
account.flush_recordset(['active'])
results_2 = self.env['account.account']._get_most_frequent_accounts_for_partner(
company_id=self.env.company.id,
partner_id=partner.id,
move_type="out_invoice"
)
self.assertFalse(account.id in results_2, "Deprecated account should NOT appear in account suggestions")
self.assertFalse(account.id in results_2, "Archived account should NOT appear in account suggestions")
@freeze_time('2017-01-01')
def test_placeholder_code(self):
""" Test that the placeholder code is '{code_in_company} ({company})'
where `company` is the first of the user's companies that is
in `account.company_ids`.
Check that `_field_to_sql` gives the same value.
"""
def get_placeholder_code_via_sql(account):
account_query = account._as_query()
placeholder_code_sql = account_query.select(account._field_to_sql('account_account', 'placeholder_code', account_query))
placeholder_code = self.env.execute_query(placeholder_code_sql)[0][0]
return placeholder_code
# This user cannot access company 2, so it can't access the created account.
user_2 = new_test_user(
self.env,
name="User that can't access company 2",
login='user_that_cannot_access_company_2',
password='user_that_cannot_access_company_2',
email='user_that_cannot_access_company_2@test.com',
group_ids=self.get_default_groups().ids,
company_id=self.env.company.id,
)
account = self.env['account.account'].create([{
'name': 'My account',
'company_ids': [Command.set(self.company_data_2['company'].ids)],
'code': '180001',
}])
self.assertEqual(account.placeholder_code, '180001 (company_2)')
self.assertEqual(get_placeholder_code_via_sql(account), '180001 (company_2)')
self.assertEqual(account.with_company(self.company_data_2['company']).placeholder_code, '180001')
self.assertEqual(get_placeholder_code_via_sql(account.with_company(self.company_data_2['company'])), '180001')
# Invalidate in order to recompute `placeholder_code` with `user_2`
account.invalidate_recordset(fnames=['placeholder_code'])
self.assertEqual(account.with_user(user_2).sudo().placeholder_code, False)
self.assertEqual(get_placeholder_code_via_sql(account.with_user(user_2).sudo()), None)
def test_account_accessible_by_search_in_sudo_mode(self):
""" Test that even if an account isn't accessible by the current user, it is returned by a search in sudo mode. """
account = self.env['account.account'].with_company(self.company_data_2['company']).create([{
'name': 'Account in Company 2',
'code': '180002',
}])
# This user can't access company 2, so it can't access the created account.
user_that_cannot_access_company_2 = new_test_user(
self.env,
name="User that can't access company 2",
login='user_that_cannot_access_company_2',
password='user_that_cannot_access_company_2',
email='user_that_cannot_access_company_2@test.com',
group_ids=self.get_default_groups().ids,
company_id=self.env.company.id,
)
searched_account = self.env['account.account'].with_user(user_that_cannot_access_company_2).sudo().search([('id', '=', account.id)])
self.assertEqual(searched_account, account)
@freeze_time('2018-01-01')
def test_account_opening_balance(self):
company = self.env.company
account = self.company_data['default_account_revenue']
@ -402,15 +705,15 @@ class TestAccountAccount(AccountTestInvoicingCommon):
self.assertRecordValues(company.account_opening_move_id.line_ids.sorted(), [
# pylint: disable=bad-whitespace
{'account_id': account.id, 'balance': 300.0},
{'account_id': balancing_account.id, 'balance': 200.0},
{'account_id': account.id, 'balance': -500.0},
{'account_id': balancing_account.id, 'balance': 200.0},
])
account.opening_balance = 0
self.cr.precommit.run()
self.assertFalse(company.account_opening_move_id.line_ids)
account.currency_id = self.currency_data['currency']
account.currency_id = self.other_currency
account.opening_debit = 100
self.cr.precommit.run()
self.assertRecordValues(company.account_opening_move_id.line_ids.sorted(), [
@ -444,9 +747,9 @@ class TestAccountAccount(AccountTestInvoicingCommon):
self.assertRecordValues(company.account_opening_move_id.line_ids.sorted(), [
# pylint: disable=bad-whitespace
{'account_id': account.id, 'balance': 100.0, 'amount_currency': 200.0},
{'account_id': balancing_account.id, 'balance': 800.0, 'amount_currency': 800.0},
{'account_id': account.id, 'balance': 100.0, 'amount_currency': 200.0},
{'account_id': account.id, 'balance': -1000.0, 'amount_currency': -2000.0},
{'account_id': balancing_account.id, 'balance': 800.0, 'amount_currency': 800.0},
])
account.opening_debit = 1000
@ -456,3 +759,317 @@ class TestAccountAccount(AccountTestInvoicingCommon):
{'account_id': account.id, 'balance': 1000.0, 'amount_currency': 2000.0},
{'account_id': account.id, 'balance': -1000.0, 'amount_currency': -2000.0},
])
def test_unmerge(self):
company_1 = self.company_data['company']
company_2 = self.company_data_2['company']
# 1. Create a merged account.
# First, set-up various fields pointing to the accounts before merging
accounts = self.env['account.account']._load_records([
{
'xml_id': f'account.{company_1.id}_test_account_1',
'values': {
'name': 'My First Account',
'code': '100234',
'account_type': 'asset_receivable',
'company_ids': [Command.link(company_1.id)],
'tax_ids': [Command.link(self.company_data['default_tax_sale'].id)],
'tag_ids': [Command.link(self.env.ref('account.account_tag_operating').id)],
},
},
{
'xml_id': f'account.{company_2.id}_test_account_2',
'values': {
'name': 'My Second Account',
'code': '100235',
'account_type': 'asset_receivable',
'company_ids': [Command.link(company_2.id)],
'tax_ids': [Command.link(self.company_data_2['default_tax_sale'].id)],
'tag_ids': [Command.link(self.env.ref('account.account_tag_investing').id)],
},
},
])
referencing_records = {
account: self._create_references_to_account(account)
for account in accounts
}
# Create the merged account by merging `accounts`
wizard = self._create_account_merge_wizard(accounts)
wizard.action_merge()
self.assertFalse(accounts[1].exists())
# Check that the merged account has correct values
account_to_unmerge = accounts[0]
self.assertRecordValues(account_to_unmerge, [{
'company_ids': [company_1.id, company_2.id],
'name': 'My First Account',
'code': '100234',
'tax_ids': [self.company_data['default_tax_sale'].id, self.company_data_2['default_tax_sale'].id],
'tag_ids': [self.env.ref('account.account_tag_operating').id, self.env.ref('account.account_tag_investing').id],
}])
self.assertRecordValues(account_to_unmerge.with_company(company_2), [{'code': '100235'}])
self.assertEqual(self.env['account.chart.template'].ref('test_account_1'), account_to_unmerge)
self.assertEqual(self.env['account.chart.template'].with_company(company_2).ref('test_account_2'), account_to_unmerge)
for referencing_records_for_account in referencing_records.values():
for referencing_record, fname in referencing_records_for_account.items():
expected_field_value = account_to_unmerge.ids if referencing_record._fields[fname].type == 'many2many' else account_to_unmerge.id
self.assertRecordValues(referencing_record, [{fname: expected_field_value}])
# Step 2: Unmerge the account
new_account = account_to_unmerge.with_context({
'account_unmerge_confirm': True,
'allowed_company_ids': [company_1.id, company_2.id],
})._action_unmerge()
# Check that the account fields are correct
self.assertRecordValues(account_to_unmerge, [{
'company_ids': [company_1.id],
'name': 'My First Account',
'code': '100234',
'tax_ids': self.company_data['default_tax_sale'].ids,
'tag_ids': [self.env.ref('account.account_tag_operating').id, self.env.ref('account.account_tag_investing').id],
}])
self.assertRecordValues(account_to_unmerge.with_company(company_2), [{'code': False}])
self.assertRecordValues(new_account.with_company(company_2), [{
'company_ids': [company_2.id],
'name': 'My First Account',
'code': '100235',
'tax_ids': self.company_data_2['default_tax_sale'].ids,
'tag_ids': [self.env.ref('account.account_tag_operating').id, self.env.ref('account.account_tag_investing').id],
}])
self.assertRecordValues(new_account, [{'code': False}])
# Check that the referencing records were correctly unmerged
new_account_by_old_account = {
account_to_unmerge: account_to_unmerge,
accounts[1]: new_account,
}
for account, referencing_records_for_account in referencing_records.items():
for referencing_record, fname in referencing_records_for_account.items():
expected_account = new_account_by_old_account[account]
expected_field_value = expected_account.ids if referencing_record._fields[fname].type == 'many2many' else expected_account.id
self.assertRecordValues(referencing_record, [{fname: expected_field_value}])
# Check that the XMLids were correctly unmerged
self.assertEqual(self.env['account.chart.template'].ref('test_account_1'), account_to_unmerge)
self.assertEqual(self.env['account.chart.template'].with_company(company_2).ref('test_account_2'), new_account)
def test_account_code_mapping(self):
company_3 = self.env['res.company'].create({'name': 'company_3'})
account = self.env['account.account'].create({
'code': 'test1',
'name': 'Test Account',
'account_type': 'asset_current',
})
# Write to DB so that the account gets an ID, and invalidate cache for code_mapping_ids so that they will be looked up
account.invalidate_recordset(['code_mapping_ids'])
account = account.with_context({'allowed_company_ids': [self.company_data['company'].id, self.company_data_2['company'].id, company_3.id]})
with Form(account) as account_form:
# Test that the code mapping gives correct values once the form has been opened (which should call search)
self.assertRecordValues(account.code_mapping_ids, [
{'company_id': self.company_data['company'].id, 'code': 'test1'},
{'company_id': self.company_data_2['company'].id, 'code': False},
{'company_id': company_3.id, 'code': False},
])
# Test that we are able to set a new code for companies 2 and 3 via the company mapping
with account_form.code_mapping_ids.edit(1) as code_mapping_form:
code_mapping_form.code = 'test2'
with account_form.code_mapping_ids.edit(2) as code_mapping_form:
code_mapping_form.code = 'test3'
# Test that writing codes and companies at the same time doesn't trigger the constraint
# that the code must be set for each company in company_ids
account_form.company_ids.add(self.company_data_2['company'])
account_form.company_ids.add(company_3)
self.assertRecordValues(account.with_company(self.company_data_2['company'].id), [{'code': 'test2'}])
self.assertRecordValues(account.with_company(company_3.id), [{'code': 'test3'}])
def test_account_code_mapping_create(self):
""" Similar as above, except test that you can create an account while specifying multiple codes in the code mapping tab. """
company_3 = self.env['res.company'].create({'name': 'company_3'})
AccountAccount = self.env['account.account'].with_context(
{'allowed_company_ids': [self.company_data['company'].id, self.company_data_2['company'].id, company_3.id]}
)
with Form(AccountAccount) as account_form:
expected_code_mapping_vals_list = [
{'company_id': self.company_data['company'].id, 'code': False},
{'company_id': self.company_data_2['company'].id, 'code': False},
{'company_id': company_3.id, 'code': False},
]
actual_code_mapping_vals_list = account_form.code_mapping_ids._records
for expected_code_mapping_vals, actual_code_mapping_vals in zip(expected_code_mapping_vals_list, actual_code_mapping_vals_list):
for key, expected_val in expected_code_mapping_vals.items():
self.assertEqual(actual_code_mapping_vals[key], expected_val)
account_form.name = "My Test Account"
account_form.code = 'test1'
account_form.account_type = 'asset_current'
with account_form.code_mapping_ids.edit(1) as code_mapping_form:
code_mapping_form.code = 'test2'
with account_form.code_mapping_ids.edit(2) as code_mapping_form:
code_mapping_form.code = 'test3'
# Test that writing codes and companies at the same time doesn't trigger the constraint
# that the code must be set for each company in company_ids
account_form.company_ids.add(self.company_data_2['company'])
account_form.company_ids.add(company_3)
account = account_form.record
self.assertRecordValues(account, [{
'company_ids': [self.company_data['company'].id, self.company_data_2['company'].id, company_3.id],
'code': 'test1'
}])
self.assertRecordValues(account.with_company(self.company_data_2['company'].id), [{'code': 'test2'}])
self.assertRecordValues(account.with_company(company_3.id), [{'code': 'test3'}])
def test_account_group_hierarchy_consistency(self):
""" Test if the hierarchy of account groups is consistent when creating, deleting and recreating an account group """
def create_account_group(name, code_prefix, company):
return self.env['account.group'].create({
'name': name,
'code_prefix_start': code_prefix,
'code_prefix_end': code_prefix,
'company_id': company.id
})
group_1 = create_account_group('group_1', 1, self.env.company)
group_10 = create_account_group('group_10', 10, self.env.company)
group_100 = create_account_group('group_100', 100, self.env.company)
group_101 = create_account_group('group_101', 101, self.env.company)
self.assertEqual(len(group_1.parent_id), 0)
self.assertEqual(group_10.parent_id, group_1)
self.assertEqual(group_100.parent_id, group_10)
self.assertEqual(group_101.parent_id, group_10)
# Delete group_101 and recreate it
group_101.unlink()
group_101 = create_account_group('group_101', 101, self.env.company)
self.assertEqual(len(group_1.parent_id), 0)
self.assertEqual(group_10.parent_id, group_1)
self.assertEqual(group_100.parent_id, group_10)
self.assertEqual(group_101.parent_id, group_10)
# The root becomes a child and vice versa
group_3 = create_account_group('group_3', 3, self.env.company)
group_31 = create_account_group('group_31', 31, self.env.company)
group_3.code_prefix_start = 312
self.assertEqual(len(group_31.parent_id), 0)
self.assertEqual(group_3.parent_id, group_31)
def test_muticompany_account_groups(self):
"""
Ensure that account groups are always in a root company
Ensure that accounts and account groups from a same company tree match
"""
branch_company = self.env['res.company'].create({
'name': 'Branch Company',
'parent_id': self.env.company.id,
})
parent_group = self.env['account.group'].create({
'name': 'Parent Group',
'code_prefix_start': '123',
'code_prefix_end': '124'
})
child_group = self.env['account.group'].with_company(branch_company).create({
'name': 'Child Group',
'code_prefix_start': '125',
'code_prefix_end': '126',
})
self.assertEqual(
child_group.company_id,
child_group.company_id.root_id,
"company_id should never be a branch company"
)
branch_account = self.env['account.account'].with_company(branch_company).create({
'name': 'Branch Account',
'code': '1234',
})
self.assertEqual(
branch_account.group_id,
parent_group,
"group_id computation should work for accounts that are not in the root company"
)
parent_account = self.env['account.account'].create({
'name': 'Parent Account',
'code': '1235'
})
parent_account.with_company(branch_company).code = '1256'
self.assertEqual(
parent_account.with_company(branch_company).group_id,
child_group,
"group_id computation should work if company_id is not in self.env.companies"
)
def test_compute_account(self):
account_sale = self.company_data['default_account_revenue'].copy()
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'account_id': account_sale.id,
'product_id': self.product_a.id,
'quantity': 1,
'price_unit': 100,
})
]
})
self.assertEqual(invoice.invoice_line_ids.account_id, account_sale)
invoice.line_ids._compute_account_id()
self.assertEqual(invoice.invoice_line_ids.account_id, self.company_data['default_account_revenue'])
def test_access_to_parent_accounts_from_branch(self):
""" Ensure that a user with access to a branch can access to the accounts of the parent company """
parent_company = self.env['res.company'].create([{
'name': "Parent Company",
}])
branch = self.env['res.company'].create([{
'name': "Branch Company",
'parent_id': parent_company.id,
}])
self.env['account.account'].create([{
'name': 'Parent Account',
'code': '444719',
'company_ids': [Command.link(parent_company.id)]
}])
# create a user with account rights and access to the branch company only
branch_user = self.env['res.users'].create({
'login': 'branch',
'name': 'XYZ',
'email': 'xyz@example.com',
'group_ids': [Command.link(self.env.ref('account.group_account_user').id)],
'company_ids': [Command.link(branch.id)],
'company_id': branch.id,
})
parent_accounts = self.env['account.account'].search([('company_ids', '=', parent_company.id)])
self.assertEqual(len(parent_accounts), 1, "There should be 1 account in the parent company")
branch_accounts = self.env['account.account'].search([('company_ids', '=', branch.id)])
self.assertEqual(len(branch_accounts), 0, "There should be no account in the branch company")
# get the accounts from the parent company with the branch user
accounts = self.env['account.account'].with_user(branch_user.id).search([('company_ids', 'parent_of', [branch.id])])
self.assertEqual(len(accounts), 1, "Branch user should have access to the accounts of the parent company")

View file

@ -1,41 +1,144 @@
# -*- coding: utf-8 -*-
import logging
import time
from odoo.fields import Domain
from odoo.modules.loading import force_demo
from odoo.tools import make_index_name, SQL
from odoo.tools.translate import TranslationImporter
from odoo.tests import standalone
from odoo.addons.account.models.chart_template import AccountChartTemplate
from unittest.mock import patch
_logger = logging.getLogger(__name__)
def _load_file(self, filepath, lang, xmlids=None, module=None, original=TranslationImporter.load_file):
self.imported_langs.add(lang)
return original(self, filepath, lang, xmlids=xmlids, module=module)
@standalone('all_l10n')
def test_all_l10n(env):
""" This test will install all the l10n_* modules.
As the module install is not yet fully transactional, the modules will
remain installed after the test.
"""
assert env.ref('base.module_account').demo, "Need the demo to test with data"
l10n_mods = env['ir.module.module'].search([
('name', 'like', 'l10n%'),
try_loading = type(env['account.chart.template']).try_loading
def try_loading_patch(self, template_code, company, install_demo=True, force_create=True):
self = self.with_context(l10n_check_fields_complete=True)
return try_loading(self, template_code, company, install_demo, force_create)
# Ensure the presence of demo data, to see if they can be correctly installed
if not env.ref('base.module_account').demo:
force_demo(env)
# Install prerequisite modules
_logger.info('Installing prerequisite modules')
pre_mods = env['ir.module.module'].search([
('name', 'in', (
'stock_account',
'mrp_accountant',
)),
('state', '=', 'uninstalled'),
])
l10n_mods.button_immediate_install()
env.reset() # clear the set of environments
env = env() # get an environment that refers to the new registry
pre_mods.button_immediate_install()
coas = env['account.chart.template'].search([
('id', 'not in', env['res.company'].search([]).chart_template_id.ids)
# Install the requirements
_logger.info('Installing all l10n modules')
l10n_mods = env['ir.module.module'].search([
'|',
('name', '=like', 'l10n_%'),
('name', '=like', 'test_l10n_%'),
('state', '=', 'uninstalled'),
])
for coa in coas:
cname = 'company_%s' % str(coa.id)
company = env['res.company'].create({
'name': cname,
'country_id': coa.country_id.id,
})
with patch.object(AccountChartTemplate, 'try_loading', try_loading_patch),\
patch.object(TranslationImporter, 'load_file', _load_file):
l10n_mods.button_immediate_install()
# In all_l10n tests we need to verify demo data
demo_failures = env['ir.demo_failure'].search([])
if demo_failures:
_logger.warning("Error while testing demo data for all_l10n tests.")
for failure in demo_failures:
_logger.warning("Demo data of module %s has failed: %s",
failure.module_id.name, failure.error)
env.transaction.reset() # clear the set of environments
idxs = []
for model in env.registry.values():
if not model._auto:
continue
for field in model._fields.values():
# TODO: handle non-orm indexes where the account field is alone or first
if not field.store or field.index \
or field.type != 'many2one' \
or field.comodel_name != 'account.account':
continue
idxname = make_index_name(model._table, field.name)
env.cr.execute(SQL(
"CREATE INDEX IF NOT EXISTS %s ON %s (%s)%s",
SQL.identifier(idxname),
SQL.identifier(model._table),
SQL.identifier(field.name),
SQL("") if field.required else SQL(" WHERE %s IS NOT NULL", SQL.identifier(field.name)),
))
idxs.append(idxname)
# Install Charts of Accounts
_logger.info('Loading chart of account')
already_loaded_codes = set(env['res.company'].search([]).mapped('chart_template'))
not_loaded_codes = [
(template_code, template)
for template_code, template in env['account.chart.template']._get_chart_template_mapping().items()
if template_code not in already_loaded_codes
# We can't make it disappear from the list, but we raise a UserError if it's not already the COA
and template_code not in ('syscohada', 'syscebnl')
]
companies = env['res.company'].create([
{
'name': f'company_coa_{template_code}',
'country_id': template['country_id'],
}
for template_code, template in not_loaded_codes
])
env.cr.commit()
# Install the CoAs
start = time.time()
env.cr.execute('ANALYZE')
logger = logging.getLogger('odoo.loading')
logger.runbot('ANALYZE took %s seconds', time.time() - start) # not sure this one is useful
for (template_code, _template), company in zip(not_loaded_codes, companies):
env.user.company_ids += company
env.user.company_id = company
_logger.info('Testing COA: %s (company: %s)' % (coa.name, cname))
_logger.info('Testing COA: %s (company: %s)', template_code, company.name)
try:
with env.cr.savepoint():
coa.try_loading()
env['account.chart.template'].with_context(l10n_check_fields_complete=True).try_loading(template_code, company, install_demo=True)
env.cr.commit()
if company.fiscal_position_ids and not company.domestic_fiscal_position_id:
_logger.warning("No domestic fiscal position found in fiscal data for %s %s.", company.country_id.name, template_code)
elif company.fiscal_position_ids:
potential_domestic_fps = company.fiscal_position_ids.filtered_domain(
Domain('country_id', '=', company.country_id.id)
| Domain([
('country_id', '=', False),
('country_group_id', 'in', company.country_id.country_group_ids.ids),
]),
)
if len(potential_domestic_fps) > 1:
potential_domestic_fps.sorted(lambda x: x.country_id.id or float('inf')).sorted('sequence')
if ((potential_domestic_fps[0].country_id == potential_domestic_fps[1].country_id) and
(potential_domestic_fps[0].sequence == potential_domestic_fps[1].sequence)):
_logger.warning("Several fiscal positions fitting for being tagged as domestic were found in fiscal data for %s %s.", company.country_id.name, template_code)
except Exception:
_logger.error("Error when creating COA %s", coa.name, exc_info=True)
_logger.error("Error when creating COA %s", template_code, exc_info=True)
env.cr.rollback()
env.cr.execute(SQL("DROP INDEX %s", SQL(", ").join(map(SQL.identifier, idxs))))
env.cr.commit()

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo.exceptions import ValidationError, UserError
from odoo import fields, Command
@ -11,30 +10,20 @@ import base64
class TestAccountBankStatementLine(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.currency_1 = cls.company_data['currency']
# We need a third currency as you could have a company's currency != journal's currency !=
cls.currency_data_2 = cls.setup_multi_currency_data(default_values={
'name': 'Dark Chocolate Coin',
'symbol': '🍫',
'currency_unit_label': 'Dark Choco',
'currency_subunit_label': 'Dark Cacao Powder',
}, rate2016=6.0, rate2017=4.0)
cls.currency_data_3 = cls.setup_multi_currency_data(default_values={
'name': 'Black Chocolate Coin',
'symbol': '🍫',
'currency_unit_label': 'Black Choco',
'currency_subunit_label': 'Black Cacao Powder',
}, rate2016=12.0, rate2017=8.0)
cls.currency_2 = cls.setup_other_currency('EUR')
cls.currency_3 = cls.setup_other_currency('CAD', rates=[('2016-01-01', 6.0), ('2017-01-01', 4.0)])
cls.currency_4 = cls.setup_other_currency('GBP', rates=[('2016-01-01', 12.0), ('2017-01-01', 8.0)])
cls.company_data_2 = cls.setup_other_company()
cls.bank_journal_1 = cls.company_data['default_journal_bank']
cls.bank_journal_2 = cls.bank_journal_1.copy()
cls.bank_journal_3 = cls.bank_journal_2.copy()
cls.currency_1 = cls.company_data['currency']
cls.currency_2 = cls.currency_data['currency']
cls.currency_3 = cls.currency_data_2['currency']
cls.currency_4 = cls.currency_data_3['currency']
cls.statement = cls.env['account.bank.statement'].create({
'name': 'test_statement',
@ -191,7 +180,7 @@ class TestAccountBankStatementLine(AccountTestInvoicingCommon):
}])
# Check the account.bank.statement.line is still correct after editing the account.move.
statement_line.move_id.write({'line_ids': [
statement_line.move_id.with_context(skip_readonly_check=True).write({'line_ids': [
(1, liquidity_lines.id, {
'debit': expected_liquidity_values.get('debit', 0.0),
'credit': expected_liquidity_values.get('credit', 0.0),
@ -355,7 +344,7 @@ class TestAccountBankStatementLine(AccountTestInvoicingCommon):
def test_constraints(self):
def assertStatementLineConstraint(statement_line_vals):
with self.assertRaises(Exception), self.cr.savepoint():
with self.assertRaises(Exception):
self.env['account.bank.statement.line'].create(statement_line_vals)
statement_line_vals = {
@ -401,12 +390,12 @@ class TestAccountBankStatementLine(AccountTestInvoicingCommon):
'move_id': st_line.move_id.id,
},
]
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaises(UserError):
st_line.move_id.write({
'line_ids': [(0, 0, vals) for vals in addition_lines_to_create]
})
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaises(UserError):
st_line.line_ids.create(addition_lines_to_create)
def test_statement_line_move_onchange_1(self):
@ -703,6 +692,18 @@ class TestAccountBankStatementLine(AccountTestInvoicingCommon):
'date': fields.Date.from_string('2020-01-13')},
])
# computing validity of non-consecutive statement shouldn't affect validity
line5 = self.create_bank_transaction(-10, '2020-01-13')
statement4 = self.env['account.bank.statement'].create({
'line_ids': [Command.set(line5.ids)],
'balance_start': -15,
})
(statement1 + statement4).invalidate_recordset(['is_valid'])
self.assertRecordValues(statement1 + statement4, [
{'is_valid': True},
{'is_valid': True},
])
# adding a statement to the first line should make statement1 invalid
line1.statement_id = statement2
statement2.flush_model()
@ -1455,10 +1456,19 @@ class TestAccountBankStatementLine(AccountTestInvoicingCommon):
move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=move.ids).create({
'date': fields.Date.from_string('2021-02-01'),
'refund_method': 'cancel',
'journal_id': self.bank_journal_1.id,
})
reversal = move_reversal.reverse_moves()
reversed_move = self.env['account.move'].browse(reversal['res_id'])
self.assertEqual(reversed_move.partner_id, partner)
def test_bank_transaction_creation_with_default_journal_entry_date(self):
invoice_date_field = self.env['ir.model.fields'].search([('model', '=', 'account.move'), ('name', '=', 'invoice_date')], limit=1)
self.env['ir.default'].create({
'field_id': invoice_date_field.id,
'json_value': '"2023-10-16"',
})
transaction = self.create_bank_transaction(1, '2020-01-10', journal=self.bank_journal_1)
assert transaction.date == transaction.move_id.date == fields.Date.from_string('2020-01-10')

View file

@ -0,0 +1,478 @@
from odoo import Command, fields
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountBillPartialDeductibility(AccountTestInvoicingCommon):
def test_simple_bill_partial_deductibility(self):
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.today(),
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 25.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 3.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -115.0, 'tax_ids': []}, # noqa: E241
],
{}
)
bill.invoice_line_ids[0].quantity = 2
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 200.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -50.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 7.5, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 22.5, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -230.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 50.0, 'tax_ids': []}, # noqa: E241
],
{}
)
bill.invoice_line_ids[0].price_unit = 50.0
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 3.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -115.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 25.0, 'tax_ids': []}, # noqa: E241
],
{}
)
bill.invoice_line_ids[0].deductible_amount = 100.0
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 15.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -115.0, 'tax_ids': []}, # noqa: E241
],
{}
)
bill.invoice_line_ids[0].deductible_amount = 75.0
bill.action_post()
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -115.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': bill.name + ' - private part', 'balance': 25.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': bill.name + ' - private part (taxes)', 'balance': 3.75, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_identical_lines(self):
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
}),
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 50.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 7.5, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 22.5, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -230.0, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_several_invoice_lines(self):
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item 1',
'price_unit': 100,
'quantity': 3,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
}),
Command.create({
'name': 'Partial item 2',
'price_unit': 150,
'quantity': 1,
'deductible_amount': 80.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
}),
Command.create({
'name': 'Full item',
'price_unit': 200,
'quantity': 1,
'deductible_amount': 100.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
}),
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Full item', 'balance': 200.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'product', 'name': 'Partial item 1', 'balance': 300.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'product', 'name': 'Partial item 2', 'balance': 150.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item 1', 'balance': -75.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item 2', 'balance': -30.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 105.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 15.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 81.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -747.5, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_different_taxes(self):
tax_21 = self.tax_purchase_a.copy({
'name': '21%',
'amount': 21,
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [
Command.set((self.tax_purchase_a + tax_21).ids),
],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id, tax_21.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id, tax_21.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 25.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 9.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '21%', 'balance': 15.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -136.0, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_several_lines_with_different_taxes(self):
tax_21 = self.tax_purchase_a.copy({
'name': '21%',
'amount': 21,
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item 1',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [
Command.set(self.tax_purchase_a.ids),
],
}),
Command.create({
'name': 'Partial item 2',
'price_unit': 120,
'quantity': 2,
'deductible_amount': 50.00,
'tax_ids': [
Command.set(tax_21.ids),
],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item 1', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'product', 'name': 'Partial item 2', 'balance': 240.0, 'tax_ids': [tax_21.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item 1', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item 2', 'balance': -120.0, 'tax_ids': [tax_21.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 145.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 28.95, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '21%', 'balance': 25.2, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -405.4, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_discounts(self):
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'discount': 50,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 50.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -12.5, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 12.5, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 1.88, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 5.63, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -57.51, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_partial_deductibility_with_cash_rounding(self):
cash_rounding = self.env['account.cash.rounding'].create({
'name': 'Rounding 10',
'rounding_method': 'HALF-UP',
'rounding': 10,
'profit_account_id': self.cash_rounding_a.profit_account_id.id,
'loss_account_id': self.cash_rounding_a.loss_account_id.id,
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_cash_rounding_id': cash_rounding.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': 100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': -25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 25.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 3.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': 11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'rounding', 'name': 'Rounding 10', 'balance' : 5.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': -120.0, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_simple_refund_partial_deductibility(self):
bill = self.env['account.move'].create({
'move_type': 'in_refund',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'Partial item', 'balance': -100.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product', 'name': 'Partial item', 'balance': 25.0, 'tax_ids': [self.tax_purchase_a.id]}, # noqa: E241
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': -25.0, 'tax_ids': []}, # noqa: E241
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': -3.75, 'tax_ids': []}, # noqa: E241
{'display_type': 'tax', 'name': '15%', 'balance': -11.25, 'tax_ids': []}, # noqa: E241
{'display_type': 'payment_term', 'name': False, 'balance': 115.0, 'tax_ids': []}, # noqa: E241
],
{}
)
def test_bill_non_deductible_tax_in_tax_totals(self):
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2024-01-01',
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
expected_values = {
'same_tax_base': False,
'currency_id': self.env.company.currency_id.id,
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'total_amount_currency': 115.0,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'tax_groups': [
{
'id': self.tax_purchase_a.tax_group_id.id,
'non_deductible_tax_amount': -3.75,
'non_deductible_tax_amount_currency': -3.75,
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'display_base_amount_currency': 75.0,
},
],
},
],
}
self._assert_tax_totals_summary(bill.tax_totals, expected_values)
def test_refund_non_deductible_tax_in_tax_totals(self):
refund = self.env['account.move'].create({
'move_type': 'in_refund',
'partner_id': self.partner_a.id,
'invoice_date': '2024-01-01',
'invoice_line_ids': [
Command.create({
'name': 'Partial item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 75.00,
'tax_ids': [Command.set(self.tax_purchase_a.ids)],
})
]
})
expected_values = {
'same_tax_base': False,
'currency_id': self.env.company.currency_id.id,
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'total_amount_currency': 115.0,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'tax_groups': [
{
'id': self.tax_purchase_a.tax_group_id.id,
'non_deductible_tax_amount': -3.75,
'non_deductible_tax_amount_currency': -3.75,
'base_amount_currency': 100.0,
'tax_amount_currency': 15.0,
'display_base_amount_currency': 75.0,
},
],
},
],
}
self._assert_tax_totals_summary(refund.tax_totals, expected_values)
def test_bill_partial_deductibility_with_reverse_charge(self):
tax_reverse_charge = self.env['account.tax'].create({
'name': 'Reverse Charge 20%',
'amount_type': 'percent',
'amount': 20.0,
'type_tax_use': 'purchase',
'invoice_repartition_line_ids': [
Command.create({'repartition_type': 'base'}),
Command.create({'repartition_type': 'tax', 'factor_percent': 100.0, 'account_id': self.company_data['default_account_tax_purchase'].id}),
Command.create({'repartition_type': 'tax', 'factor_percent': -100.0, 'account_id': self.company_data['default_account_tax_purchase'].id}),
],
'refund_repartition_line_ids': [
Command.create({'repartition_type': 'base'}),
Command.create({'repartition_type': 'tax', 'factor_percent': 100.0, 'account_id': self.company_data['default_account_tax_purchase'].id}),
Command.create({'repartition_type': 'tax', 'factor_percent': -100.0, 'account_id': self.company_data['default_account_tax_purchase'].id}),
],
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.today(),
'invoice_line_ids': [
Command.create({
'name': 'RC Partial Item',
'price_unit': 100,
'quantity': 1,
'deductible_amount': 60.00,
'tax_ids': [Command.set(tax_reverse_charge.ids)],
})
]
})
self.assertInvoiceValues(
bill,
[
{'display_type': 'product', 'name': 'RC Partial Item', 'balance': 100.0, 'tax_ids': [tax_reverse_charge.id]},
{'display_type': 'non_deductible_product', 'name': 'RC Partial Item', 'balance': -40.0, 'tax_ids': [tax_reverse_charge.id]},
{'display_type': 'non_deductible_product_total', 'name': 'private part', 'balance': 40.0, 'tax_ids': []},
{'display_type': 'non_deductible_tax', 'name': 'private part (taxes)', 'balance': 8.0, 'tax_ids': []},
{'display_type': 'tax', 'name': 'Reverse Charge 20%', 'balance': -20.0, 'tax_ids': []},
{'display_type': 'tax', 'name': 'Reverse Charge 20%', 'balance': 12.0, 'tax_ids': []},
{'display_type': 'payment_term', 'name': False, 'balance': -100.0, 'tax_ids': []},
],
{}
)

View file

@ -1,34 +1,86 @@
from contextlib import closing
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.models import Model
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo import fields
from odoo.tests import Form, tagged
from odoo import fields, Command
from odoo.exceptions import UserError
from odoo.tools import format_date
from unittest.mock import patch
@tagged('post_install', '-at_install')
class TestAccountMoveInalterableHash(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
def _init_and_post(self, vals, hash_version=False, secure_sequence=None):
moves = self.env['account.move']
for val in vals:
move = self.init_invoice("out_invoice", val['partner'], val['date'], amounts=val['amounts'], journal=val.get('journal'), post=False)
if secure_sequence: # Simulate old behavior (pre hash v4)
move.secure_sequence_number = secure_sequence.next_by_id()
if hash_version:
move = move.with_context(hash_version=hash_version)
move.action_post()
moves |= move
return moves
def _skip_hash_moves(self):
def _do_not_hash_moves(self, **kwargs):
pass
return patch('odoo.addons.account.models.account_move.AccountMove._hash_moves', new=_do_not_hash_moves)
def _reverse_move(self, move):
reversal = move._reverse_moves()
reversal.action_post()
return reversal
def _get_secure_sequence(self):
"""Before hash v4, we had a secure_sequence on hashed journals.
We removed it starting v4, however, to test previous versions, we need to create it
to mock the old behavior."""
return self.env['ir.sequence'].create({
'name': 'SECURE_SEQUENCE',
'code': 'SECURE_SEQUENCE',
'implementation': 'no_gap',
'prefix': '',
'suffix': '',
'padding': 0,
'company_id': self.company_data['company'].id,
})
def _verify_integrity(self, moves, expected_msg_cover, expected_first_move=None, expected_last_move=None, prefix=None):
integrity_check = moves.company_id._check_hash_integrity()['results']
name = prefix or moves[0].sequence_prefix
integrity_check = next(filter(lambda r: name in r.get('journal_name'), integrity_check))
self.assertRegex(integrity_check['msg_cover'], expected_msg_cover)
if expected_first_move and expected_last_move:
self.assertEqual(integrity_check['first_move_name'], expected_first_move.name)
self.assertEqual(integrity_check['last_move_name'], expected_last_move.name)
def test_account_move_inalterable_hash(self):
"""Test that we cannot alter a field used for the computation of the inalterable hash"""
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
move = self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000], post=True)
self.company_data['default_journal_purchase'].restrict_mode_hash_table = True
move = self._init_and_post([{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000]}])
in_invoice = self.init_invoice("in_invoice", self.partner_a, "2023-01-01", amounts=[1000], post=True)
# in_invoice and out_invoice should both be hashed on post
self.assertNotEqual(move.inalterable_hash, False)
self.assertNotEqual(in_invoice.inalterable_hash, False)
with self.assertRaisesRegex(UserError, "You cannot overwrite the values ensuring the inalterability of the accounting."):
common = "This document is protected by a hash. Therefore, you cannot edit the following fields:"
with self.assertRaisesRegex(UserError, f"{common}.*Inalterability Hash."):
move.inalterable_hash = 'fake_hash'
with self.assertRaisesRegex(UserError, "You cannot overwrite the values ensuring the inalterability of the accounting."):
move.secure_sequence_number = 666
with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"):
with self.assertRaisesRegex(UserError, f"{common}.*Inalterability Hash."):
in_invoice.inalterable_hash = "fake_hash"
with self.assertRaisesRegex(UserError, f"{common}.*Number."):
move.name = "fake name"
with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"):
with self.assertRaisesRegex(UserError, f"{common}.*Date."):
move.date = fields.Date.from_string('2023-01-02')
with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"):
with self.assertRaisesRegex(UserError, f"{common}.*Company."):
move.company_id = 666
with self.assertRaisesRegex(UserError, "You cannot edit the following fields due to restrict mode being activated.*"):
with self.assertRaisesRegex(UserError, f"{common}(.*Company.*Date.)|(.*Date.*Company.)"):
move.write({
'company_id': 666,
'date': fields.Date.from_string('2023-01-03')
@ -45,211 +97,201 @@ class TestAccountMoveInalterableHash(AccountTestInvoicingCommon):
def test_account_move_hash_integrity_report(self):
"""Test the hash integrity report"""
moves = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
)
moves.action_post()
moves = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-02', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-01', 'amounts': [1000, 2000]},
])
# No records to be hashed because the restrict mode is not activated yet
integrity_check = moves.company_id._check_hash_integrity()['results'][0] # First journal
self.assertEqual(integrity_check['msg_cover'], 'This journal is not in strict mode.')
for result in moves.company_id._check_hash_integrity()['results']:
self.assertEqual(result['status'], 'no_data')
# No records to be hashed even if the restrict mode is activated because the hashing is not retroactive
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], 'There isn\'t any journal entry flagged for data inalterability yet for this journal.')
# Everything should be correctly hashed and verified
new_moves = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-03", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-04", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_a, "2023-01-05", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-06", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_a, "2023-01-07", amounts=[1000, 2000])
# First sequence
first_chain_moves = moves | self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-05', 'amounts': [1000, 2000]},
{'partner': self.partner_a, 'date': '2023-01-04', 'amounts': [1000, 2000]}, # We don't care about the date order, just the sequence_prefix and sequence_number
{'partner': self.partner_b, 'date': '2023-01-06', 'amounts': [1000, 2000]},
{'partner': self.partner_a, 'date': '2023-01-07', 'amounts': [1000, 2000]},
])
moves = first_chain_moves
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1])
# Second sequence
second_chain_moves_first_move = self.init_invoice("out_invoice", self.partner_a, "2023-01-08", amounts=[1000, 2000])
second_chain_moves_first_move.name = "XYZ/1"
second_chain_moves_first_move.action_post()
second_chain_moves = (
second_chain_moves_first_move
| self._init_and_post([
{'partner': self.partner_b, 'date': '2023-01-09', 'amounts': [1000, 2000]},
{'partner': self.partner_a, 'date': '2023-01-12', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-11', 'amounts': [1000, 2000]},
{'partner': self.partner_a, 'date': '2023-01-12', 'amounts': [1000, 2000]},
])
)
new_moves.action_post()
moves |= new_moves
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[2].name}.*')
self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[2].date)))
self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date)))
# First sequence again
first_chain_moves_new_move = self.init_invoice("out_invoice", self.partner_a, "2023-01-08", amounts=[1000, 2000])
first_chain_moves_new_move.name = first_chain_moves[-1].name[:-1] + str(int(first_chain_moves[-1].name[-1]) + 1)
first_chain_moves_new_move.action_post()
first_chain_moves |= first_chain_moves_new_move
# Verification of the two chains.
moves = first_chain_moves | second_chain_moves
self._verify_integrity(moves, "Entries are correctly hashed", first_chain_moves[0], first_chain_moves[-1], 'INV/')
self._verify_integrity(moves, "Entries are correctly hashed", second_chain_moves[0], second_chain_moves[-1], 'XYZ/')
# Let's change one of the fields used by the hash. It should be detected by the integrity report.
# We need to bypass the write method of account.move to do so.
Model.write(moves[4], {'date': fields.Date.from_string('2023-01-07')})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[4].id}.')
date_hashed = first_chain_moves[3].date
Model.write(first_chain_moves[3], {'date': fields.Date.from_string('2023-02-07')})
self._verify_integrity(moves, f'Corrupted data on journal entry with id {first_chain_moves[3].id}.*')
# Revert the previous change
Model.write(moves[4], {'date': fields.Date.from_string("2023-01-05")})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[2].name}.*')
Model.write(first_chain_moves[3], {'date': date_hashed})
self._verify_integrity(moves, "Entries are correctly hashed", first_chain_moves[0], first_chain_moves[-1], 'INV/')
# Let's try with the one of the subfields
Model.write(moves[-1].line_ids[0], {'partner_id': self.partner_b.id})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[-1].id}.')
Model.write(second_chain_moves[-1].line_ids[0], {'partner_id': self.partner_b.id})
self._verify_integrity(moves, f'Corrupted data on journal entry with id {second_chain_moves[-1].id}.*', prefix='XYZ/')
# Let's try with the inalterable_hash field itself
Model.write(moves[-1].line_ids[0], {'partner_id': self.partner_a.id}) # Revert the previous change
Model.write(moves[-1], {'inalterable_hash': 'fake_hash'})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[-1].id}.')
Model.write(first_chain_moves[-1].line_ids[0], {'partner_id': self.partner_a.id}) # Revert the previous change
Model.write(first_chain_moves[-1], {'inalterable_hash': 'fake_hash'})
self._verify_integrity(moves, f'Corrupted data on journal entry with id {first_chain_moves[-1].id}.*', prefix='INV/')
def test_account_move_hash_versioning_1(self):
"""We are updating the hash algorithm. We want to make sure that we do not break the integrity report.
This test focuses on the case where the user has only moves with the old hash algorithm."""
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-04", amounts=[1000, 2000])
)
moves.with_context(hash_version=1).action_post()
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*')
self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date)))
self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date)))
secure_sequence = self._get_secure_sequence()
moves = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-02', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-04', 'amounts': [1000, 2000]},
], hash_version=1, secure_sequence=secure_sequence)
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], prefix=moves[0].sequence_prefix)
# Let's change one of the fields used by the hash. It should be detected by the integrity report
# independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work.
# We need to bypass the write method of account.move to do so.
Model.write(moves[1], {'date': fields.Date.from_string('2023-01-07')})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[1].id}.')
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves[1].id}.*', prefix=moves[0].sequence_prefix)
def test_account_move_hash_versioning_2(self):
"""We are updating the hash algorithm. We want to make sure that we do not break the integrity report.
This test focuses on the case where the user has only moves with the new hash algorithm."""
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves.action_post()
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*')
self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date)))
self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date)))
secure_sequence = self._get_secure_sequence()
moves = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-02', 'amounts': [1000, 2000]},
], hash_version=2, secure_sequence=secure_sequence)
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], prefix=moves[0].sequence_prefix)
# Let's change one of the fields used by the hash. It should be detected by the integrity report
# independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work.
# We need to bypass the write method of account.move to do so.
Model.write(moves[1], {'date': fields.Date.from_string('2023-01-07')})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[1].id}.')
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves[1].id}.*', prefix=moves[0].sequence_prefix)
def test_account_move_hash_versioning_v1_to_v2(self):
"""We are updating the hash algorithm. We want to make sure that we do not break the integrity report.
This test focuses on the case where the user has moves with both hash algorithms."""
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000], post=True) # Not hashed
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves_v1 = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves_v1.with_context(hash_version=1).action_post()
secure_sequence = self._get_secure_sequence()
moves_v1 = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-02', 'amounts': [1000, 2000]},
], hash_version=1, secure_sequence=secure_sequence)
fields_v1 = moves_v1.with_context(hash_version=1)._get_integrity_hash_fields()
moves_v2 = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves_v2.with_context(hash_version=2).action_post()
moves_v2 = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-02', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-01', 'amounts': [1000, 2000]},
], hash_version=2, secure_sequence=secure_sequence)
fields_v2 = moves_v2._get_integrity_hash_fields()
self.assertNotEqual(fields_v1, fields_v2) # Make sure two different hash algorithms were used
moves = moves_v1 | moves_v2
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*')
self.assertEqual(integrity_check['first_move_date'], format_date(self.env, fields.Date.to_string(moves[0].date)))
self.assertEqual(integrity_check['last_move_date'], format_date(self.env, fields.Date.to_string(moves[-1].date)))
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], prefix=moves[0].sequence_prefix)
# Let's change one of the fields used by the hash. It should be detected by the integrity report
# independently of the hash version used. I.e. we first try the v1 hash, then the v2 hash and neither should work.
# We need to bypass the write method of account.move to do so.
date_hashed = moves[4].date
Model.write(moves[4], {'date': fields.Date.from_string('2023-01-07')})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[4].id}.')
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves[4].id}.*', prefix=moves[0].sequence_prefix)
# Let's revert the change and make sure that we cannot use the v1 after the v2.
# This means we don't simply check whether the move is correctly hashed with either algorithms,
# but that we can only use v2 after v1 and not go back to v1 afterwards.
Model.write(moves[4], {'date': fields.Date.from_string("2023-01-02")}) # Revert the previous change
moves_v1_bis = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-10", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-11", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-12", amounts=[1000, 2000])
)
moves_v1_bis.with_context(hash_version=1).action_post()
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves_v1_bis[0].id}.')
Model.write(moves[4], {'date': date_hashed}) # Revert the previous change
moves_v1_bis = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-10', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-11', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-12', 'amounts': [1000, 2000]},
], hash_version=1, secure_sequence=secure_sequence)
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves_v1_bis[0].id}.*', prefix=moves[0].sequence_prefix)
def test_account_move_hash_versioning_3(self):
"""
Version 2 does not take into account floating point representation issues.
Test that version 3 covers correctly this case
"""
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000],
post=True) # Not hashed
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves_v3 = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[30*0.17, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves_v3.action_post()
secure_sequence = self._get_secure_sequence()
moves = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [30 * 0.17, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-02', 'amounts': [1000, 2000]},
], hash_version=3, secure_sequence=secure_sequence)
# invalidate cache
moves_v3[0].line_ids[0].invalidate_recordset()
moves[0].line_ids[0].invalidate_recordset()
integrity_check_v3 = moves_v3.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check_v3['msg_cover'], f'Entries are hashed from {moves_v3[0].name}.*')
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], prefix=moves[0].sequence_prefix)
def test_account_move_hash_versioning_v2_to_v3(self):
"""
We are updating the hash algorithm. We want to make sure that we do not break the integrity report.
This test focuses on the case with version 2 and version 3.
"""
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000],
post=True) # Not hashed
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves_v2 = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves_v2.with_context(hash_version=2).action_post()
secure_sequence = self._get_secure_sequence()
moves_v2 = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
], hash_version=2, secure_sequence=secure_sequence)
moves_v3 = (
self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-02", amounts=[1000, 2000])
| self.init_invoice("out_invoice", self.partner_b, "2023-01-03", amounts=[1000, 2000])
)
moves_v3.with_context(hash_version=3).action_post()
moves_v3 = self._init_and_post([
{'partner': self.partner_a, 'date': '2023-01-02', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-01', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2023-01-03', 'amounts': [1000, 2000]},
], hash_version=3, secure_sequence=secure_sequence)
moves = moves_v2 | moves_v3
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertRegex(integrity_check['msg_cover'], f'Entries are hashed from {moves[0].name}.*')
self.assertEqual(integrity_check['first_move_date'],
format_date(self.env, fields.Date.to_string(moves[0].date)))
self.assertEqual(integrity_check['last_move_date'],
format_date(self.env, fields.Date.to_string(moves[-1].date)))
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], prefix=moves[0].sequence_prefix)
Model.write(moves[1], {'date': fields.Date.from_string('2023-01-07')})
integrity_check = moves.company_id._check_hash_integrity()['results'][0]
self.assertEqual(integrity_check['msg_cover'], f'Corrupted data on journal entry with id {moves[1].id}.')
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves[1].id}.*', prefix=moves[0].sequence_prefix)
def test_account_move_hash_with_cash_rounding(self):
# Enable inalterable hash
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
# Required for `invoice_cash_rounding_id` to be visible in the view
self.env.user.groups_id += self.env.ref('account.group_cash_rounding')
self.env.user.group_ids += self.env.ref('account.group_cash_rounding')
# Test 'add_invoice_line' rounding
invoice = self.init_invoice('out_invoice', products=self.product_a+self.product_b)
move_form = Form(invoice)
@ -261,9 +303,538 @@ class TestAccountMoveInalterableHash(AccountTestInvoicingCommon):
# Should not raise
invoice.action_post()
invoice._generate_and_send(allow_fallback_pdf=False)
self.assertEqual(invoice.amount_total, 1410.0)
self.assertEqual(invoice.amount_untaxed, 1200.0)
self.assertEqual(invoice.amount_tax, 210)
self.assertEqual(len(invoice.invoice_line_ids), 2)
self.assertEqual(len(invoice.line_ids), 6)
def test_retroactive_hashing(self):
"""The hash should be retroactive even to moves that were created before the restrict mode was activated."""
move1 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
self.assertFalse(move1.inalterable_hash)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
move2 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
self.assertNotEqual(move2.inalterable_hash, False)
self.assertNotEqual(move1.inalterable_hash, False)
self._verify_integrity(move1 | move2, "Entries are correctly hashed", move1, move2)
def test_retroactive_hashing_backwards_compatibility(self):
"""
Simulate old version where the hash was not retroactive
We should not consider these moves now either
We should hash after the last moved hashed
"""
move1 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
# Posting a new move also posts the previous move (move1)
self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
Model.write(move1, {'inalterable_hash': False, 'secure_sequence_number': 0})
# The following should only compute the hash for move3, not move1 (move2 is already hashed)
move3 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
self.assertNotEqual(move3.inalterable_hash, False)
self.assertFalse(move1.inalterable_hash)
def test_no_hash_if_hole_in_sequence(self):
"""If there is a hole in the sequence, we should not hash the moves"""
move1 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
move2 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
move3 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
move2.button_draft() # Create hole in the middle of unhashed chain [move1, move2, move3]
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
with self.assertRaisesRegex(UserError, "An error occurred when computing the inalterability. A gap has been detected in the sequence."):
move3.button_hash()
move1.button_hash() # Afterwards move2 is a hole at the beginning of the unhashed part of the chain [move1 (hashed), move2, move3]
with self.assertRaisesRegex(UserError, "An error occurred when computing the inalterability. A gap has been detected in the sequence."):
move3.button_hash()
with self._skip_hash_moves():
move2.action_post()
move3.button_hash() # Shouldn't raise
for move in (move1, move2, move3):
self.assertNotEqual(move.inalterable_hash, False)
with self._skip_hash_moves():
move4 = self._reverse_move(move1)
move5 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=False)
move5.action_post()
move6 = self._reverse_move(move2)
move7 = self._reverse_move(move3)
self._verify_integrity(move1, "Entries are correctly hashed", move1, move3)
for move in (move4, move6, move7):
self.assertFalse(move.inalterable_hash)
move7.button_hash() # Shouldn't raise, no sequence hole if we have a mix of invoices and credit notes
self.assertFalse(move5.inalterable_hash) # move5 has another sequence_prefix, so not hashed here
for move in (move4, move6, move7):
self.assertNotEqual(move.inalterable_hash, False)
move8 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
for move in (move5 | move8):
self.assertNotEqual(move.inalterable_hash, False)
moves = (move1 | move2 | move3 | move4 | move5 | move6 | move7 | move8)
self._verify_integrity(moves, "Entries are correctly hashed", move1, move8, prefix='INV/')
self._verify_integrity(moves, "Entries are correctly hashed", move4, move7, prefix='RINV/')
def test_retroactive_hash_vendor_bills(self):
"""The hash should be retroactive even to vendor bills that were created before the restrict mode was activated."""
move1 = self.init_invoice("in_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
self.company_data['default_journal_purchase'].restrict_mode_hash_table = True
move2 = self.init_invoice("in_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
# We should hash vendor bills on post
self.assertNotEqual(move1.inalterable_hash, False)
self.assertNotEqual(move2.inalterable_hash, False)
self._verify_integrity(move1 | move2, "Entries are correctly hashed", move1, move2)
def test_retroactive_hash_multiple_journals(self):
"""If we have a recordset of moves in different journals, all of them should be hashed
in a way that respects the journal to which they belong"""
journal_sale2 = self.env['account.journal'].create({
'name': 'Sale Journal 2',
'type': 'sale',
'code': 'SJ2',
'company_id': self.company_data['company'].id,
})
move1 = self.env['account.move'].create({
'move_type': 'out_invoice',
'journal_id': journal_sale2.id,
'partner_id': self.partner_a.id,
'date': '2024-01-01',
'invoice_line_ids': [Command.create({
'name': 'test',
'quantity': 1,
'price_unit': 1000,
'account_id': self.company_data['default_account_revenue'].id,
})],
})
move1.action_post()
self.assertFalse(move1.inalterable_hash)
move2 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
self.assertFalse(move2.inalterable_hash)
move3 = self.env['account.move'].create({
'move_type': 'out_invoice',
'journal_id': journal_sale2.id,
'partner_id': self.partner_a.id,
'date': '2024-01-01',
'invoice_line_ids': [Command.create({
'name': 'test',
'quantity': 1,
'price_unit': 1000,
'account_id': self.company_data['default_account_revenue'].id,
})],
})
move3.action_post()
self.assertFalse(move3.inalterable_hash)
move4 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
self.assertFalse(move4.inalterable_hash)
moves = move1 | move2 | move3 | move4
journal_sale2.restrict_mode_hash_table = True
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves.button_hash()
for move in moves:
self.assertNotEqual(move.inalterable_hash, False)
self._verify_integrity(moves, "Entries are correctly hashed", move1, move3, prefix=move1.sequence_prefix)
self._verify_integrity(moves, "Entries are correctly hashed", move2, move4, prefix=move2.sequence_prefix)
def test_hash_multiyear(self):
"""Test that we can hash entries from different fiscal years"""
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
move1 = self.init_invoice("out_invoice", self.partner_a, "2023-01-01", amounts=[1000], post=True)
move2 = self.init_invoice("out_invoice", self.partner_a, "2023-01-03", amounts=[1000], post=True)
move3 = self.init_invoice("out_invoice", self.partner_a, "2023-01-02", amounts=[1000], post=True)
move4 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
move5 = self.init_invoice("out_invoice", self.partner_a, "2024-01-03", amounts=[1000], post=True)
move6 = self.init_invoice("out_invoice", self.partner_a, "2024-01-04", amounts=[1000], post=True)
moves = move1 | move2 | move3 | move4 | move5 | move6
for move in moves:
self.assertNotEqual(move.inalterable_hash, False)
self._verify_integrity(moves, "Entries are correctly hashed", move1, move3, prefix=move1.sequence_prefix)
self._verify_integrity(moves, "Entries are correctly hashed", move4, move6, prefix=move4.sequence_prefix)
def test_hash_on_lock_date(self):
"""
The lock date and hashing should not interfere with each other.
* We should be able to hash moves protected by a lock date.
* We should be able to lock a period containing unhashed moves.
"""
for lock_date_field in [
'hard_lock_date',
'fiscalyear_lock_date',
'sale_lock_date',
]:
with self.subTest(lock_date_field=lock_date_field), closing(self.env.cr.savepoint()):
move1 = self.init_invoice('out_invoice', self.partner_a, "2024-01-01", amounts=[1000], post=True)
move2 = self.init_invoice('out_invoice', self.partner_a, "2024-01-02", amounts=[1000], post=True)
move3 = self.init_invoice('out_invoice', self.partner_a, "2024-01-03", amounts=[1000], post=True)
move4 = self.init_invoice('out_invoice', self.partner_a, "2024-02-01", amounts=[1000], post=True)
move5 = self.init_invoice('out_invoice', self.partner_a, "2024-02-01", amounts=[1000], post=True)
for move in (move1, move2, move3, move4, move5):
self.assertFalse(move.inalterable_hash)
# Shouldn't raise (case no moves have ever been hashed)
self.company_data['company'][lock_date_field] = fields.Date.to_date('2024-01-31')
# Let's has just one and revert the lock date
if lock_date_field == 'hard_lock_date':
def _validate_locks(*args, **kwargs):
pass
with patch('odoo.addons.account.models.company.ResCompany._validate_locks', new=_validate_locks):
self.company_data['company'][lock_date_field] = False
else:
self.company_data['company'][lock_date_field] = False
move1.button_hash()
# We should be able to set the lock date (case there are hashed moves)
self.company_data['company'][lock_date_field] = fields.Date.to_date('2024-01-31')
for move in (move2, move3, move4, move5):
self.assertFalse(move.inalterable_hash)
# We should be able to hash the moves despite the lock date
move5.button_hash()
for move in (move1, move2, move3, move4, move5):
self.assertNotEqual(move.inalterable_hash, False)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True # to run integrity check
self._verify_integrity(move5, "Entries are correctly hashed", move1, move5)
def test_retroactive_hashing_before_current(self):
"""Test that we hash entries before the current recordset of moves, not the ones after"""
move1 = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000], post=True)
move2 = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000], post=True)
move3 = self.init_invoice("out_invoice", self.partner_a, "2024-01-03", amounts=[1000], post=True)
move4 = self.init_invoice("out_invoice", self.partner_a, "2024-01-04", amounts=[1000], post=True)
move5 = self.init_invoice("out_invoice", self.partner_a, "2024-01-05", amounts=[1000], post=True)
move6 = self.init_invoice("out_invoice", self.partner_a, "2024-01-06", amounts=[1000], post=True)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
move3.button_hash()
for move in (move1, move2, move3):
self.assertNotEqual(move.inalterable_hash, False)
for move in (move4, move5, move6):
self.assertFalse(move.inalterable_hash)
self._verify_integrity(move1, "Entries are correctly hashed", move1, move3)
move6.button_hash()
self._verify_integrity(move1, "Entries are correctly hashed", move1, move6)
def test_account_move_hash_versioning_v3_to_v4(self):
"""
We are updating the hash algorithm. We want to make sure that we do not break the integrity report.
This test focuses on the case with version 3 and version 4.
"""
# Let's simulate v3 where the hash was on post and not retroactive
# First let's create some moves that shouldn't be hashed (before restrict mode)
secure_sequence = self._get_secure_sequence()
moves_v3_pre_restrict_mode = self.env['account.move']
for _ in range(3):
moves_v3_pre_restrict_mode |= self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000, 2000], post=True)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
# Now create some moves in v3 that should be hashed on post and have a secure_sequence_id
moves_v3_post_restrict_mode = self.env['account.move']
last_hash = ""
for _ in range(3):
move = self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000, 2000], post=False)
with self._skip_hash_moves():
move.action_post()
move.inalterable_hash = move.with_context(hash_version=3)._calculate_hashes(last_hash)[move]
last_hash = move.inalterable_hash
move.secure_sequence_number = secure_sequence.next_by_id()
moves_v3_post_restrict_mode |= move
moves_v3 = moves_v3_pre_restrict_mode | moves_v3_post_restrict_mode
# Use v4 now
moves_v4 = self._init_and_post([
{'partner': self.partner_a, 'date': '2024-01-02', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2024-01-01', 'amounts': [1000, 2000]},
{'partner': self.partner_b, 'date': '2024-01-03', 'amounts': [1000, 2000]},
]) # Default hash version is 4
# Don't ever allow to hash moves_v3_pre_restrict_mode
moves_v3_pre_restrict_mode[-1]._hash_moves(raise_if_no_document=False) # Shouldn't raise
self.assertFalse(moves_v3_pre_restrict_mode[-1].inalterable_hash)
with self.assertRaisesRegex(UserError, "This move could not be locked either because.*"):
moves_v3_pre_restrict_mode.button_hash()
# Test that we allow holes that are not in the moves_to_hash and
moves_v3_pre_restrict_mode[2].button_draft() # Create hole in sequence
moves_v4 |= self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000, 2000], post=True)
with self._skip_hash_moves():
moves_v3_pre_restrict_mode[2].action_post() # Revert
# Check lock date, shouldn't raise even if there are no documents to hash
self.company_data['company'].fiscalyear_lock_date = fields.Date.to_date('2024-01-31')
# We should have something like (mix of v3 and v4):
# Name | Secure Sequence Number | Inalterable Hash
# --------------|---------------------------|------------------
# ### V3: Restricted mode not activated yet
# INV/2024/1 | 0 | False
# INV/2024/2 | 0 | False
# INV/2024/3 | 0 | False
# ### V3: Restricted mode activated + hash on post
# INV/2024/4 | 1 | 87ba4c8...
# INV/2024/5 | 2 | 09al8if...
# INV/2024/6 | 3 | 0a9f8a9...
# ### V4: No secure_sequence_number, hash retroactively on send&print (17.2 to 17.4) or post (17.5+)
# INV/2024/7 | 0 | $4$aj98na1...
# INV/2024/8 | 0 | $4$9177iai...
# INV/2024/9 | 0 | $4$nwy7ao9
# ### INV/2024/1, INV/2024/2, INV/2024/3 should not be hashed
for move in moves_v3_pre_restrict_mode:
self.assertFalse(move.inalterable_hash)
self.assertFalse(move.secure_sequence_number)
for move in moves_v3_post_restrict_mode:
self.assertNotEqual(move.inalterable_hash, False)
self.assertNotEqual(move.secure_sequence_number, False)
for move in moves_v4:
self.assertNotEqual(move.inalterable_hash, False)
self.assertFalse(move.secure_sequence_number)
moves = moves_v3 | moves_v4
self._verify_integrity(moves, "Entries are correctly hashed", moves_v3_post_restrict_mode[0], moves[-1], prefix=moves[0].sequence_prefix)
Model.write(moves_v3_post_restrict_mode[1], {'date': fields.Date.from_string('2024-11-07')})
self._verify_integrity(moves, f'Corrupted data on journal entry with id {moves_v3_post_restrict_mode[1].id}.*', prefix=moves[0].sequence_prefix)
def test_inalterable_hash_verification_by_batches(self):
"""Test that the integrity report can handle a large amount of entries by
verifying the integrity by batches."""
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
moves = self.env['account.move']
for _ in range(10):
moves |= self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000, 2000], post=True)
for move in moves:
self.assertNotEqual(move.inalterable_hash, False)
with patch('odoo.addons.account.models.company.INTEGRITY_HASH_BATCH_SIZE', 3):
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], moves[0].journal_id.name)
with patch('odoo.addons.account.models.company.INTEGRITY_HASH_BATCH_SIZE', 5):
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], moves[0].journal_id.name)
with patch('odoo.addons.account.models.company.INTEGRITY_HASH_BATCH_SIZE', 10):
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], moves[0].journal_id.name)
with patch('odoo.addons.account.models.company.INTEGRITY_HASH_BATCH_SIZE', 12):
self._verify_integrity(moves, "Entries are correctly hashed", moves[0], moves[-1], moves[0].journal_id.name)
def test_error_on_unreconciled_bank_statement_lines(self):
"""
Check that an error is raised when we try to hash entries with unreconciled bank statement lines.
"""
unreconciled_bank_statement_line = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
'date': '2017-01-01',
'payment_ref': '2017_unreconciled',
'amount': 10.0,
})
unreconciled_move = unreconciled_bank_statement_line.move_id
unreconciled_move.journal_id.restrict_mode_hash_table = True
with self.assertRaisesRegex(UserError, "An error occurred when computing the inalterability. All entries have to be reconciled."):
unreconciled_move.button_hash()
def test_account_move_unhashed_entries(self):
"""
Test that when _get_chain_info is called with early_stop=True (e.g., when checking if a journal has unhashed
entries), no error is raised and the right value is returned based on whether there are unhashed documents.
"""
sales_journal = self.company_data['default_journal_sale']
# Create a move before the journal is set to 'Hash on post', allowing to test if the journal has unhashed entries.
self._init_and_post([{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000]}])
sales_journal.restrict_mode_hash_table = True
# There should be unhashed entries in the sales journal until another move is posted
self.assertTrue(sales_journal._get_moves_to_hash(include_pre_last_hash=False, early_stop=True))
self._init_and_post([{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000]}])
# After posting one entry, sales journal shouldn't have unhashed entries
self.assertFalse(sales_journal._get_moves_to_hash(include_pre_last_hash=False, early_stop=True))
def test_account_group_account_secured(self):
"""
Test that user is not granted the group account secured if only entries from a journal without 'Hash on Post' is
secured. Once entries from a journal without 'Hash on Post' are secured, the user is granted the access rights.
"""
group_account_secured = self.env.ref('account.group_account_secured')
# `group_account_secured` can be by default in user groups (e.g. l10n_de)
group_account_secured_in_user_groups = group_account_secured in self.env.user.group_ids.all_implied_ids
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
move = self._init_and_post([{'partner': self.partner_a, 'date': '2023-01-01', 'amounts': [1000]}])
self.assertNotEqual(move.inalterable_hash, False)
# Unless `group_account_secured` was by default in user groups, user shouldn't be granted access rights since
# only moves from a journal with 'Hash on Post' have been secured
if not group_account_secured_in_user_groups:
self.assertFalse(group_account_secured in self.env.user.group_ids.all_implied_ids)
# Once moves from a journal without 'Hash on Post' is secured, user should be granted secured group access rights
in_invoice = self.init_invoice("in_invoice", self.partner_a, "2023-01-01", amounts=[1000], post=True)
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2023-01-02'})
wizard.action_secure_entries()
self.assertNotEqual(in_invoice.inalterable_hash, False)
self.assertTrue(group_account_secured in self.env.user.group_ids.all_implied_ids)
def test_wizard_hashes_all_journals(self):
"""
Test that the wizard hashes all journals.
* Regardless of the `restrict_mode_hash_table` setting on the journal.
* Regardless of the lock date
"""
moves = self.env['account.move'].create([
{
'date': '2023-01-02',
'journal_id': self.env['account.journal'].create({
'code': f'wiz{idx}',
'name': f'Wizard {journal_type}',
'type': journal_type,
}).id,
'line_ids': [Command.create({
'name': 'test',
'quantity': 1,
'balance': 0,
'account_id': self.company_data['default_account_revenue'].id,
})],
} for idx, journal_type in enumerate(('sale', 'purchase', 'cash', 'bank', 'credit', 'general'))
])
moves.action_post()
self.company_data['company'].hard_lock_date = '2023-01-02'
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2023-01-02'})
wizard.action_secure_entries()
self.assertTrue(False not in moves.mapped('inalterable_hash'))
def test_wizard_ignores_sequence_prefixes_with_unreconciled_entries(self):
"""
Test that the wizard does not try to hash sequence prefixes containing unreconciled bank statement lines.
But it should still hash the remaining sequence prefixes from the same journal.
"""
# Create 2 reconciled moves from different sequences
reconciled_bank_statement_line_2016 = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
'date': '2016-01-01',
'payment_ref': '2016_reconciled',
'amount': 0.0, # reconciled
})
reconciled_bank_statement_line_2017 = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
'date': '2017-01-01',
'payment_ref': '2017_reconciled',
'amount': 0.0, # reconciled
})
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2017-01-01'})
reconciled_bank_statement_lines = reconciled_bank_statement_line_2016 | reconciled_bank_statement_line_2017
self.assertFalse(wizard.unreconciled_bank_statement_line_ids)
self.assertEqual(wizard.move_to_hash_ids, reconciled_bank_statement_lines.move_id)
# Create an unreconciled move for the 2017 prefix
unreconciled_bank_statement_line_2017 = self.env['account.bank.statement.line'].create({
'journal_id': self.company_data['default_journal_bank'].id,
'date': '2017-01-01',
'payment_ref': '2017_unreconciled',
'amount': 10.0,
})
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2017-01-01'})
self.assertEqual(wizard.unreconciled_bank_statement_line_ids, unreconciled_bank_statement_line_2017)
self.assertEqual(wizard.move_to_hash_ids, reconciled_bank_statement_line_2016.move_id)
def test_wizard_backwards_compatibility(self):
"""
The wizard was introduced in odoo 17.5 when the hash version was 4.
We check that:
* We do not hash unhashed moves before the start of the hash sequence
* The wizard displays information about the date of the first unhashed move:
This excludes moves before the hard lock date.
"""
# Let's simulate v3 where the hash was on post and not retroactive
# First let's create some moves that shouldn't be hashed (before restrict mode)
secure_sequence = self._get_secure_sequence()
moves_v3_pre_restrict_mode = self.env['account.move']
for _ in range(3):
moves_v3_pre_restrict_mode |= self.init_invoice("out_invoice", self.partner_a, "2024-01-01", amounts=[1000, 2000], post=True)
self.company_data['default_journal_sale'].restrict_mode_hash_table = True
# Now create some moves in v3 that should be hashed on post and have a secure_sequence_id
moves_v3_post_restrict_mode = self.env['account.move']
last_hash = ""
for _ in range(3):
move = self.init_invoice("out_invoice", self.partner_a, "2024-01-02", amounts=[1000, 2000], post=False)
with self._skip_hash_moves():
move.action_post()
move.inalterable_hash = move.with_context(hash_version=3)._calculate_hashes(last_hash)[move]
last_hash = move.inalterable_hash
move.secure_sequence_number = secure_sequence.next_by_id()
moves_v3_post_restrict_mode |= move
moves_v4 = self.init_invoice("out_invoice", self.partner_a, "2024-01-03", amounts=[1000], post=False)
moves_v4 |= self.init_invoice("out_invoice", self.partner_a, "2024-01-03", amounts=[1000], post=False)
moves_v4 |= self.init_invoice("out_invoice", self.partner_a, "2024-01-03", amounts=[1000], post=False)
with self._skip_hash_moves():
moves_v4.action_post()
for move in moves_v3_pre_restrict_mode | moves_v4:
self.assertFalse(move.inalterable_hash)
# We cannot hash the moves_v3_pre_restrict_mode because the moves_v3_post_restrict_mode are hashed
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2024-01-03'})
self.assertEqual(wizard.not_hashable_unlocked_move_ids, moves_v3_pre_restrict_mode)
self.assertEqual(wizard.move_to_hash_ids, moves_v4)
# We can still hash the remaining moves
with self.subTest(msg="Hash the remaining moves"), closing(self.env.cr.savepoint()):
wizard.action_secure_entries()
for move in moves_v3_pre_restrict_mode:
self.assertFalse(move.inalterable_hash)
for move in moves_v4:
self.assertNotEqual(move.inalterable_hash, False)
# We can ignore the moves by setting the hard lock date:
self.assertEqual(wizard.max_hash_date, fields.Date.from_string("2023-12-31"))
self.company_data['company'].hard_lock_date = "2024-01-01"
# There is nothing to hash
wizard = self.env['account.secure.entries.wizard'].create({'hash_date': '2024-01-03'})
self.assertEqual(wizard.max_hash_date, fields.Date.from_string("2024-01-02"))
self.assertFalse(wizard.not_hashable_unlocked_move_ids)
self.assertEqual(wizard.move_to_hash_ids, moves_v4)

View file

@ -1,29 +1,32 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo import fields
from odoo import Command, fields
@tagged('post_install', '-at_install')
class TestAccountInvoiceReport(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
cls.company_data_2 = cls.setup_other_company()
pack_of_six = cls.env['uom.uom'].search([('name', '=', 'Pack of 6')])
cls.invoices = cls.env['account.move'].create([
{
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2016-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 3,
'price_unit': 750,
'price_unit': 4500,
'product_uom_id': pack_of_six.id,
}),
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 3000,
@ -33,9 +36,9 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
{
'move_type': 'out_receipt',
'invoice_date': fields.Date.from_string('2016-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 6000,
@ -46,22 +49,28 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
'move_type': 'out_refund',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2017-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 1200,
}),
Command.create({
'product_id': cls.product_a.id,
'quantity': 3,
'price_unit': 4500,
'product_uom_id': pack_of_six.id,
}),
]
},
{
'move_type': 'in_invoice',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2016-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 60,
@ -72,9 +81,9 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
'move_type': 'in_receipt',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2016-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 60,
@ -85,15 +94,28 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
'move_type': 'in_refund',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2017-01-01'),
'currency_id': cls.currency_data['currency'].id,
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 12,
}),
]
},
{
'move_type': 'out_refund',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.from_string('2017-01-01'),
'currency_id': cls.other_currency.id,
'invoice_line_ids': [
Command.create({
'product_id': cls.product_a.id,
'quantity': 1,
'price_unit': 2400,
}),
]
},
])
def assertInvoiceReportValues(self, expected_values_list):
@ -102,20 +124,66 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
'price_average': vals[0],
'price_subtotal': vals[1],
'quantity': vals[2],
'price_margin': vals[3],
'inventory_value': vals[4],
} for vals in expected_values_list]
self.assertRecordValues(reports, expected_values_dict)
def test_invoice_report_multiple_types(self):
"""
Each line represent an invoice line
First and last lines use Packagings. Quantity and price from the invoice are adapted
to the standard UoM of the product.
quantity is quantity in product_uom
price_subtotal = Price_unit * Number_of_packages / currency_rate
price_average = price_subtotal / quantity
inventory_value = quantity * standard_price * (-1 OR 1 depending of move_type)
price_margin = (price_average - standard_price) * quantity
E.g. first line:
quantity : 6 * 3 = 18
price_subtotal = 4500 * 3 / 3 = 4500
price_average = 4500 / 18 = 250
inventory_value = 800*18*-1 = -14400
price_margin = (250 - 800) * 18 = -9900
"""
self.assertInvoiceReportValues([
#price_average price_subtotal quantity
[2000, 2000, 1],
[1000, 1000, 1],
[250, 750, 3],
[6, 6, 1],
[20, -20, -1],
[20, -20, -1],
[600, -600, -1],
# pylint: disable=bad-whitespace
# price_average, price_subtotal, quantity, price_margin, inventory_value
[ 250, 4500, 18, -9900, -14400], # price_unit = 4500, currency.rate = 3.0
[ 2000, 2000, 1, 1200, -800], # price_unit = 6000, currency.rate = 3.0
[ 1000, 1000, 1, 200, -800], # price_unit = 3000, currency.rate = 3.0
[ 6, 6, 1, 0, -800], # price_unit = 12, currency.rate = 2.0
[ 20, -20, -1, 0, 800], # price_unit = 60, currency.rate = 3.0
[ 20, -20, -1, 0, 800], # price_unit = 60, currency.rate = 3.0
[ 600, -600, -1, 200, 800], # price_unit = 1200, currency.rate = 2.0
[ 1200, -1200, -1, -400, 800], # price_unit = 2400, currency.rate = 2.0
[ 375, -6750, -18, 7650, 14400], # price_unit = 4500, currency.rate = 2.0
])
def test_invoice_report_multicompany_product_cost(self):
"""
In a multicompany environment, if you define one product with different standard price per company
the invoice analysis report should only display the product from the company
Standard Price in Company A: 800 (default setup)
Standard Price in Company B: 700
-> invoice report for Company A should remain the same
"""
self.product_a.with_company(self.company_data_2.get('company')).write({'standard_price': 700.0})
self.assertInvoiceReportValues([
# pylint: disable=bad-whitespace
# price_average, price_subtotal, quantity, price_margin, inventory_value
[ 250, 4500, 18, -9900, -14400], # price_unit = 4500, currency.rate = 3.0
[ 2000, 2000, 1, 1200, -800], # price_unit = 6000, currency.rate = 3.0
[ 1000, 1000, 1, 200, -800], # price_unit = 3000, currency.rate = 3.0
[ 6, 6, 1, 0, -800], # price_unit = 12, currency.rate = 2.0
[ 20, -20, -1, 0, 800], # price_unit = 60, currency.rate = 3.0
[ 20, -20, -1, 0, 800], # price_unit = 60, currency.rate = 3.0
[ 600, -600, -1, 200, 800], # price_unit = 1200, currency.rate = 2.0
[ 1200, -1200, -1, -400, 800], # price_unit = 2400, currency.rate = 2.0
[ 375, -6750, -18, 7650, 14400], # price_unit = 4500, currency.rate = 2.0
])
def test_avg_price_calculation(self):
@ -155,19 +223,125 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon):
})
invoice.action_post()
report = self.env['account.invoice.report'].read_group(
report = self.env['account.invoice.report'].formatted_read_group(
[('product_id', '=', product.id)],
[],
['price_subtotal:sum', 'quantity:sum', 'price_average:avg'],
[],
)
self.assertEqual(report[0]['quantity'], 35)
self.assertEqual(report[0]['price_subtotal'], 165)
self.assertEqual(round(report[0]['price_average'], 2), 4.71)
self.assertEqual(report[0]['quantity:sum'], 35)
self.assertEqual(report[0]['price_subtotal:sum'], 165)
self.assertEqual(round(report[0]['price_average:avg'], 2), 4.71)
# ensure that it works with only 'price_average:avg' in fields
report = self.env['account.invoice.report'].read_group(
# ensure that it works with only 'price_average:avg' in aggregates
report = self.env['account.invoice.report'].formatted_read_group(
[('product_id', '=', product.id)],
['price_average:avg'],
[],
['price_average:avg'],
)
self.assertEqual(round(report[0]['price_average'], 2), 4.71)
self.assertEqual(round(report[0]['price_average:avg'], 2), 4.71)
def test_avg_price_group_by_month(self):
"""
Check that the average is correctly calculated based on the total price and quantity
with multiple invoices and group by month:
Invoice 1:
2 lines:
- 10 units * 10$
- 5 units * 5$
Total quantity: 15
Total price: 125$
Average: 125 / 15 = 8.33
Invoice 2:
1 line:
- 0 units * 5$
Total quantity: 0
Total price: 0$
Average: 0.00
"""
self.env['account.move'].search([]).unlink()
invoices = self.env["account.move"].create([
{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2025-01-01'),
'currency_id': self.env.company.currency_id.id,
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 10,
'price_unit': 10,
}),
Command.create({
'product_id': self.product_a.id,
'quantity': 5,
'price_unit': 5,
}),
]
},
{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2025-02-01'),
'currency_id': self.env.company.currency_id.id,
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 0,
'price_unit': 5,
}),
]
},
])
invoices.action_post()
report = self.env['account.invoice.report'].formatted_read_group(
[('product_id', '=', self.product_a.id)],
['invoice_date:month'],
['__count', 'price_subtotal:sum', 'quantity:sum', 'price_average:avg'],
)
self.assertEqual(report[0]['__count'], 2)
self.assertEqual(report[0]['quantity:sum'], 15.0)
self.assertEqual(report[0]['price_subtotal:sum'], 125.0)
self.assertEqual(round(report[0]['price_average:avg'], 2), 8.33)
self.assertEqual(report[1]['__count'], 1)
self.assertEqual(report[1]['quantity:sum'], 0.0)
self.assertEqual(report[1]['price_subtotal:sum'], 0.0)
self.assertEqual(report[1]['price_average:avg'], 0.00)
def test_inventory_margin_currency(self):
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'price_unit': 750,
}),
],
})
egy_company = self.env['res.company'].create({
'name': 'Egyptian Company',
'currency_id': self.env.ref('base.EGP').id,
'user_ids': [Command.set(self.env.user.ids)],
})
orig_company = self.env.company
report = self.env['account.invoice.report'].search(
[('move_id', '=', invoice.id)],
)
self.assertEqual(report.inventory_value, -800)
self.assertEqual(report.price_margin, -50)
self.env.user.company_id = egy_company
self.env['res.currency.rate'].create({
'name': '2017-11-03',
'rate': 0.5,
'currency_id': orig_company.currency_id.id,
})
self.env.flush_all()
self.env['account.invoice.report'].invalidate_model()
report = self.env['account.invoice.report'].search(
[('move_id', '=', invoice.id)],
)
self.assertEqual(report.inventory_value, -1600)
self.assertEqual(report.price_margin, -100)

View file

@ -1,91 +1,84 @@
# -*- coding: utf-8 -*-
from ast import literal_eval
from unittest.mock import patch
from odoo import http
from odoo.tools import hash_sign
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.account.models.account_payment_method import AccountPaymentMethod
from odoo.tests import tagged
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import Form, tagged, HttpCase, new_test_user
from odoo.addons.test_mail.data.test_mail_data import MAIL_EML_ATTACHMENT
from odoo.exceptions import UserError, ValidationError
from odoo import fields, Command
@tagged('post_install', '-at_install')
class TestAccountJournal(AccountTestInvoicingCommon):
class TestAccountJournal(AccountTestInvoicingCommon, HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
cls.company_data_2 = cls.setup_other_company()
def test_constraint_currency_consistency_with_accounts(self):
''' The accounts linked to a bank/cash journal must share the same foreign currency
if specified.
'''
journal_bank = self.company_data['default_journal_bank']
journal_bank.currency_id = self.currency_data['currency']
journal_bank.currency_id = self.other_currency
# Try to set a different currency on the 'debit' account.
with self.assertRaises(ValidationError), self.cr.savepoint():
with self.assertRaises(ValidationError):
journal_bank.default_account_id.currency_id = self.company_data['currency']
def test_euro_payment_reference_generation(self):
"""
Test the generation of European (ISO 11649) payment references to ensure
it correctly handles various journal short codes.
"""
journal = self.company_data['default_journal_sale']
journal.invoice_reference_model = 'euro'
# Case 1: Code contains alphanumeric value.
journal.code = 'INV'
invoice_valid = self.init_invoice("out_invoice", products=self.product_a)
invoice_valid.journal_id = journal
invoice_valid.action_post()
self.assertTrue(invoice_valid.payment_reference, "A payment reference should be generated.")
self.assertIn('INV', invoice_valid.payment_reference, "The reference should be based on the journal code.")
# Case 2: Code contains a hyphen.
journal.code = 'INV-'
invoice_invalid = self.init_invoice("out_invoice", products=self.product_a)
invoice_invalid.journal_id = journal
invoice_invalid.action_post()
self.assertTrue(invoice_invalid.payment_reference, "A payment reference should be generated.")
self.assertIn(str(journal.id), invoice_invalid.payment_reference, "The reference should fall back to using the journal ID.")
# Case 3: Code is non-ASCII but alphanumeric (e.g., Greek letter 'INVα'). # noqa: RUF003
journal.code = 'INVα'
invoice_unicode = self.init_invoice("out_invoice", products=self.product_a)
invoice_unicode.journal_id = journal
invoice_unicode.action_post()
self.assertTrue(invoice_unicode.payment_reference, "A payment reference should be generated.")
self.assertIn(str(journal.id), invoice_unicode.payment_reference, "The reference should fall back to using the journal ID for non-ASCII codes.")
def test_changing_journal_company(self):
''' Ensure you can't change the company of an account.journal if there are some journal entries '''
self.company_data['default_journal_sale'].code = "DIFFERENT"
self.env['account.move'].create({
'move_type': 'entry',
'date': '2019-01-01',
'journal_id': self.company_data['default_journal_sale'].id,
})
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaisesRegex(UserError, "entries linked to it"):
self.company_data['default_journal_sale'].company_id = self.company_data_2['company']
def test_account_control_create_journal_entry(self):
move_vals = {
'line_ids': [
(0, 0, {
'name': 'debit',
'account_id': self.company_data['default_account_revenue'].id,
'debit': 100.0,
'credit': 0.0,
}),
(0, 0, {
'name': 'credit',
'account_id': self.company_data['default_account_expense'].id,
'debit': 0.0,
'credit': 100.0,
}),
],
}
# Should fail because 'default_account_expense' is not allowed.
self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_revenue']
with self.assertRaises(UserError), self.cr.savepoint():
self.env['account.move'].create(move_vals)
# Should be allowed because both accounts are accepted.
self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_expense']
self.env['account.move'].create(move_vals)
def test_account_control_existing_journal_entry(self):
self.env['account.move'].create({
'line_ids': [
(0, 0, {
'name': 'debit',
'account_id': self.company_data['default_account_revenue'].id,
'debit': 100.0,
'credit': 0.0,
}),
(0, 0, {
'name': 'credit',
'account_id': self.company_data['default_account_expense'].id,
'debit': 0.0,
'credit': 100.0,
}),
],
})
# There is already an other line using the 'default_account_expense' account.
with self.assertRaises(ValidationError), self.cr.savepoint():
self.company_data['default_journal_misc'].account_control_ids |= self.company_data['default_account_revenue']
# Assigning both should be allowed
self.company_data['default_journal_misc'].account_control_ids = \
self.company_data['default_account_revenue'] + self.company_data['default_account_expense']
def test_account_journal_add_new_payment_method_multi(self):
"""
Test the automatic creation of payment method lines with the mode set to multi
@ -94,7 +87,7 @@ class TestAccountJournal(AccountTestInvoicingCommon):
def _get_payment_method_information(self):
res = Method_get_payment_method_information(self)
res['multi'] = {'mode': 'multi', 'domain': [('type', '=', 'bank')]}
res['multi'] = {'mode': 'multi', 'type': ('bank',)}
return res
with patch.object(AccountPaymentMethod, '_get_payment_method_information', _get_payment_method_information):
@ -104,10 +97,11 @@ class TestAccountJournal(AccountTestInvoicingCommon):
'payment_type': 'inbound'
})
journals = self.env['account.journal'].search([('inbound_payment_method_line_ids.code', '=', 'multi')])
bank_journals_count = self.env['account.journal'].search_count([('type', '=', 'bank')])
edited_journals_count = self.env['account.journal'].search_count([('inbound_payment_method_line_ids.code', '=', 'multi')])
# The two bank journals have been set
self.assertEqual(len(journals), 2)
# The bank journals have been set
self.assertEqual(bank_journals_count, edited_journals_count)
def test_remove_payment_method_lines(self):
"""
@ -136,35 +130,13 @@ class TestAccountJournal(AccountTestInvoicingCommon):
self.assertFalse(second_method.exists())
def test_account_journal_alias_name(self):
journal = self.company_data['default_journal_purchase']
self.assertEqual(journal.alias_name, 'vendor-bills-company_1_data')
journal.name = ''
journal.alias_name = False
self.assertEqual(journal.alias_name, 'bill-company_1_data')
journal.code = ''
journal.alias_name = False
self.assertEqual(journal.alias_name, 'purchase-company_1_data')
company_2_id = str(self.company_data_2['company'].id)
journal_2 = self.company_data_2['default_journal_sale']
self.company_data_2['company'].name = ''
journal_2.alias_name = False
self.assertEqual(journal_2.alias_name, 'customer-invoices-' + company_2_id)
journal_2.name = ''
journal_2.alias_name = False
self.assertEqual(journal_2.alias_name, 'inv-' + company_2_id)
journal_2.code = ''
journal_2.alias_name = False
self.assertEqual(journal_2.alias_name, 'sale-' + company_2_id)
def test_account_journal_duplicates(self):
new_journals = self.env["account.journal"].with_context(import_file=True).create([
{"name": "OD_BLABLA"},
{"name": "OD_BLABLU"},
])
self.assertEqual(sorted(new_journals.mapped("code")), ["GEN1", "OD_BL"], "The journals should be set correctly")
self.assertEqual(sorted(new_journals.mapped("code")), ["MISC1", "OD_BL"], "The journals should be set correctly")
def test_archive_used_journal(self):
journal = self.env['account.journal'].create({
@ -205,3 +177,397 @@ class TestAccountJournal(AccountTestInvoicingCommon):
journals.action_unarchive()
self.assertTrue(journals[0].active)
self.assertTrue(journals[1].active)
def test_journal_notifications_unsubscribe(self):
journal = self.company_data['default_journal_purchase']
journal.incoming_einvoice_notification_email = 'test@example.com'
self.authenticate(self.env.user.login, self.env.user.login)
res = self.url_open(
f'/my/journal/{journal.id}/unsubscribe',
data={'csrf_token': http.Request.csrf_token(self)},
method='POST',
)
res.raise_for_status()
self.assertFalse(journal.incoming_einvoice_notification_email)
def test_journal_notifications_unsubscribe_success(self):
journal = self.company_data['default_journal_purchase']
email = 'test@example.com'
journal.incoming_einvoice_notification_email = email
self.authenticate(None, None)
token = hash_sign(
self.env,
journal._get_journal_notification_unsubscribe_scope(),
{'email_to_unsubscribe': email, 'journal_id': journal.id},
)
res = self.url_open(
f'/my/journal/{journal.id}/unsubscribe?token={token}',
data={'csrf_token': http.Request.csrf_token(self)},
method='POST',
)
res.raise_for_status()
self.assertFalse(journal.incoming_einvoice_notification_email)
def test_journal_notifications_unsubscribe_errors(self):
journal = self.company_data['default_journal_purchase']
email = 'test@example.com'
self.authenticate(None, None)
valid_token = hash_sign(
self.env(su=True),
journal._get_journal_notification_unsubscribe_scope(),
{'email_to_unsubscribe': email, 'journal_id': journal.id},
)
def _get_token():
return
def _unsubscribe(token, journal_id=journal.id):
return self.url_open(
f'/my/journal/{journal_id}/unsubscribe?token={token}',
data={'csrf_token': http.Request.csrf_token(self)},
method='POST',
)
with self.subTest('invalid_token'):
journal.incoming_einvoice_notification_email = email
res = _unsubscribe('invalid_token')
self.assertEqual(res.status_code, 403)
self.assertEqual(journal.incoming_einvoice_notification_email, email)
with self.subTest('already_unsubscribed'):
journal.incoming_einvoice_notification_email = email
first_unsubscribe = _unsubscribe(valid_token)
first_unsubscribe.raise_for_status()
self.assertFalse(journal.incoming_einvoice_notification_email)
second_unsubscribe = _unsubscribe(valid_token)
self.assertEqual(second_unsubscribe.status_code, 404)
with self.subTest('wrong_journal_id'):
journal.incoming_einvoice_notification_email = email
res = _unsubscribe(valid_token, journal_id=journal.id + 1)
self.assertEqual(res.status_code, 403)
self.assertEqual(journal.incoming_einvoice_notification_email, email)
@tagged('post_install', '-at_install', 'mail_alias')
class TestAccountJournalAlias(AccountTestInvoicingCommon, MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_data_2 = cls.setup_other_company()
def test_alias_name_creation(self):
""" Test alias creation, notably avoid raising constraints due to ascii
characters removal. See odoo/odoo@339cdffb68f91eb1455d447d1bdd7133c68723bd """
# check base test data
journal1 = self.company_data['default_journal_purchase']
company1 = journal1.company_id
journal2 = self.company_data_2['default_journal_sale']
company2 = journal2.company_id
# have a non ascii company name
company2.name = ''
for (aname, jname, jcode, jtype, jcompany), expected_alias_name in zip(
[
('youpie', 'Journal Name', 'NEW1', 'purchase', company1),
(False, 'Journal Other Name', 'NEW2', 'purchase', company1),
(False, '', 'NEW3', 'purchase', company1),
(False, '', '', 'purchase', company1),
('youpie', 'Journal Name', 'NEW1', 'purchase', company2),
(False, 'Journal Other Name', 'NEW2', 'purchase', company2),
(False, '', 'NEW3', 'purchase', company2),
(False, '', '', 'purchase', company2),
],
[
f'youpie-{company1.name}',
f'journal-other-name-{company1.name}',
f'new3-{company1.name}',
f'purchase-{company1.name}',
f'youpie-{company2.id}',
f'journal-other-name-{company2.id}',
f'new3-{company2.id}',
f'purchase-{company2.id}',
]
):
with self.subTest(aname=aname, jname=jname, jcode=jcode, jtype=jtype, jcompany=jcompany):
new_journal = self.env['account.journal'].create({
'code': jcode,
'company_id': jcompany.id,
'name': jname,
'type': jtype,
# force alias_name only if given, to check default value otherwise
**({'alias_name': aname} if aname else {}),
})
self.assertEqual(new_journal.alias_name, expected_alias_name)
# other types: no mail support by default
journals = self.env['account.journal'].create([{
'code': f'NEW{jtype}',
'name': f'Type {jtype}',
'type': jtype}
for jtype in ('general', 'cash', 'bank')
])
self.assertFalse(journals.alias_id, 'Do not create useless aliases')
self.assertFalse(list(filter(None, journals.mapped('alias_name'))))
def test_alias_name_form(self):
""" Test alias name update using Form tool (onchange) """
journal = Form(self.env['account.journal'])
journal.name = 'Test With Form'
self.assertFalse(journal.alias_name)
journal.type = 'sale'
self.assertEqual(journal.alias_name, f'test-with-form-{self.env.company.name}')
journal.type = 'cash'
self.assertFalse(journal.alias_name)
def test_alias_from_type(self):
""" Test alias behavior on journal, especially alias_name management as
well as defaults update, see odoo/odoo@400b6860271a11b9914166ff7e42939c4c6192dc """
journal = self.company_data['default_journal_purchase']
# assert base test data
company_name = 'company_1_data'
journal_code = 'BILL'
journal_name = 'Purchases'
journal_alias = journal.alias_id
self.assertEqual(journal.code, journal_code)
self.assertEqual(journal.company_id.name, company_name)
self.assertEqual(journal.name, journal_name)
self.assertEqual(journal.type, 'purchase')
# assert default creation data
self.assertEqual(journal_alias.alias_contact, 'everyone')
self.assertDictEqual(
dict(literal_eval(journal_alias.alias_defaults)),
{
'move_type': 'in_invoice',
'company_id': journal.company_id.id,
'journal_id': journal.id,
}
)
self.assertFalse(journal_alias.alias_force_thread_id, 'Journal alias should create new moves')
self.assertEqual(journal_alias.alias_model_id, self.env['ir.model']._get('account.move'),
'Journal alias targets moves')
self.assertEqual(journal_alias.alias_name, f'purchases-{company_name}')
self.assertEqual(journal_alias.alias_parent_model_id, self.env['ir.model']._get('account.journal'),
'Journal alias owned by journal itself')
self.assertEqual(journal_alias.alias_parent_thread_id, journal.id,
'Journal alias owned by journal itself')
# update alias_name, ensure a fallback on a real name when not explicit reset
for alias_name, expected in [
(False, False),
('', False),
(' ', f'purchases-{company_name}'), # error recuperation
('.', f'purchases-{company_name}'), # error recuperation
('😊', f'purchases-{company_name}'), # resets, unicode not supported
('', f'purchases-{company_name}'), # resets, non ascii not supported
('Youpie Boum', 'youpie-boum'),
]:
with self.subTest(alias_name=alias_name):
journal.write({'alias_name': alias_name})
self.assertEqual(journal.alias_name, expected)
self.assertEqual(journal_alias.alias_name, expected)
# changing type should void if not purchase or sale
for jtype in ('general', 'cash', 'bank'):
journal.write({'type': jtype})
self.assertEqual(journal.alias_id, journal_alias,
'Dà not unlink aliases, just reset their value')
self.assertFalse(journal.alias_name)
self.assertFalse(journal_alias.alias_name)
# changing type should reset if sale or purchase
journal.company_id.write({'name': 'New Company Name'})
journal.write({'name': 'Reset Journal', 'type': 'sale'})
journal_alias_2 = journal.alias_id
self.assertEqual(journal_alias_2.alias_contact, 'everyone')
self.assertDictEqual(
dict(literal_eval(journal_alias_2.alias_defaults)),
{
'move_type': 'out_invoice',
'company_id': journal.company_id.id,
'journal_id': journal.id,
}
)
self.assertFalse(journal_alias_2.alias_force_thread_id, 'Journal alias should create new moves')
self.assertEqual(journal_alias_2.alias_model_id, self.env['ir.model']._get('account.move'),
'Journal alias targets moves')
self.assertEqual(journal_alias_2.alias_name, 'reset-journal-new-company-name')
self.assertEqual(journal_alias_2.alias_parent_model_id, self.env['ir.model']._get('account.journal'),
'Journal alias owned by journal itself')
self.assertEqual(journal_alias_2.alias_parent_thread_id, journal.id,
'Journal alias owned by journal itself')
def test_alias_create_unique(self):
""" Make auto-generated alias_name unique when needed """
company_name = self.company_data['company'].name
journal = self.env['account.journal'].create({
'name': 'Test Journal',
'type': 'sale',
'code': 'A',
})
journal2 = self.env['account.journal'].create({
'name': 'Test Journal',
'type': 'sale',
'code': 'B',
})
self.assertEqual(journal.alias_name, f'test-journal-{company_name}')
self.assertEqual(journal2.alias_name, f'test-journal-{company_name}-b')
def test_non_latin_journal_code_payment_reference(self):
""" Ensure non-Latin journal codes do not cause errors and payment references are valid """
non_latin_code = 'TΠY'
latin_code = 'TPY'
journal_non_latin = self.env['account.journal'].create({
'name': 'Test Journal',
'type': 'sale',
'code': non_latin_code,
'invoice_reference_model': 'euro'
})
journal_latin = self.env['account.journal'].create({
'name': 'Test Journal',
'type': 'sale',
'code': latin_code,
'invoice_reference_model': 'euro'
})
invoice_non_latin = self.init_invoice(
move_type='out_invoice',
partner=self.partner_a,
invoice_date=fields.Date.today(),
post=True,
products=[self.product_a],
journal=journal_non_latin,
)
invoice_latin = self.init_invoice(
move_type='out_invoice',
partner=self.partner_a,
invoice_date=fields.Date.today(),
post=True,
products=[self.product_a],
journal=journal_latin,
)
expected_id = str(invoice_non_latin.journal_id.id)
ref_parts_non_latin = invoice_non_latin.payment_reference.split()
self.assertEqual(ref_parts_non_latin[1][:len(expected_id)], expected_id, "The reference should start with " + expected_id)
ref_parts_latin = invoice_latin.payment_reference.split()
self.assertIn(ref_parts_latin[1][:3], latin_code, f"Expected journal code '{latin_code}' in second part of reference")
def test_use_default_account_from_journal(self):
"""
Test that the autobalance uses the default account id of the journal
"""
autobalance_account = self.env['account.account'].create({
'name': 'Autobalance Account',
'account_type': 'income',
'code': 'A',
})
journal = self.env['account.journal'].create({
'name': 'Test Journal',
'type': 'general',
'code': 'B',
'default_account_id': autobalance_account.id,
})
entry = self.env['account.move'].create({
'move_type': 'entry',
'journal_id': journal.id,
'line_ids': [
Command.create({
'debit': 100.0,
'credit': 0.0,
'tax_ids': (self.company_data['default_tax_sale']),
'account_id': self.company_data['default_account_revenue'].id
})
]
})
entry.action_post()
self.assertRecordValues(entry.line_ids, [
{'balance': 100.0, 'account_id': self.company_data['default_account_revenue'].id},
{'balance': 15.0, 'account_id': self.company_data['default_account_tax_sale'].id},
{'balance': -115.0, 'account_id': autobalance_account.id},
])
def test_send_email_to_alias_from_other_company(self):
user_company_2 = new_test_user(
self.env,
name='company 2 user',
login='company_2_user',
password='company_2_user',
email='company_2_user@test.com',
company_id=self.company_data_2['company'].id
)
self.format_and_process(
MAIL_EML_ATTACHMENT,
user_company_2.email,
self.company_data['default_journal_purchase'].alias_email,
subject='purchase test mail',
target_model='account.move',
msg_id='<test-account-move-alias-id>',
)
self.assertTrue(self.env['account.move'].search([('invoice_source_email', '=', 'company_2_user@test.com')]))
def test_alias_uniqueness_without_domain(self):
"""Ensure alias_name is unique even if alias_domain is not defined."""
default_account = self.env['account.account'].search(
domain=[('account_type', 'in', ('income', 'income_other'))],
limit=1,
)
with Form(self.env['account.journal']) as journal_form:
journal_form.type = 'sale'
journal_form.code = 'A'
journal_form.name = 'Test Journal 1'
journal_form.default_account_id = default_account
journal_1 = journal_form.save()
with Form(self.env['account.journal']) as journal_form:
journal_form.type = 'sale'
journal_form.code = 'B'
journal_form.name = 'Test Journal 2'
journal_form.default_account_id = default_account
journal_2 = journal_form.save()
self.assertNotEqual(journal_1.alias_id.alias_name, journal_2.alias_id.alias_name)
def test_payment_method_line_accounts_on_recompute(self):
"""
Test that outstanding payments/receipts accounts are not removed during the computation of the payment method lines
"""
bank_journal = self.company_data['default_journal_bank']
outstanding_receipt_account = self.env['account.chart.template'].ref('account_journal_payment_debit_account_id')
outstanding_payment_account = self.env['account.chart.template'].ref('account_journal_payment_credit_account_id')
inbound_method_lines = bank_journal.inbound_payment_method_line_ids
inbound_method_lines_names = inbound_method_lines.mapped('name')
inbound_method_lines[0].payment_account_id = outstanding_receipt_account
outbound_method_lines = bank_journal.outbound_payment_method_line_ids
outbound_method_lines_names = outbound_method_lines.mapped('name')
outbound_method_lines[0].payment_account_id = outstanding_payment_account
new_outbound_payment_line = outbound_method_lines[0].copy({'payment_account_id': self.company_data['default_account_deferred_expense'].id})
bank_journal.outbound_payment_method_line_ids = [Command.link(new_outbound_payment_line.id)]
# Set currency_id to trigger the compute of {in,out}bound_payment_method_line_ids
bank_journal.currency_id = self.company_data['currency']
self.assertRecordValues(bank_journal.inbound_payment_method_line_ids, [
{
'name': name,
'payment_account_id': outstanding_receipt_account.id if index == 0 else False,
} for index, name in enumerate(inbound_method_lines_names)
])
self.assertRecordValues(bank_journal.outbound_payment_method_line_ids, [
{
'name': name,
'payment_account_id': outstanding_payment_account.id if index == 0 else False,
} for index, name in enumerate(outbound_method_lines_names)
])

View file

@ -2,15 +2,19 @@ from dateutil.relativedelta import relativedelta
from freezegun import freeze_time
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.account.tests.test_account_journal_dashboard_common import TestAccountJournalDashboardCommon
from odoo.tests import tagged
from odoo.tools.misc import format_amount
@tagged('post_install', '-at_install')
class TestAccountJournalDashboard(AccountTestInvoicingCommon):
class TestAccountJournalDashboard(TestAccountJournalDashboardCommon):
@freeze_time("2019-01-22")
def test_customer_invoice_dashboard(self):
# This test is defined in the account_3way_match module with different values, so we skip it when the module is installed
if self.env['ir.module.module'].search([('name', '=', 'account_3way_match')]).state == 'installed':
self.skipTest("This test won't work if account_3way_match is installed")
journal = self.company_data['default_journal_sale']
invoice = self.env['account.move'].create({
@ -44,7 +48,7 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
})
# Check Draft
dashboard_data = journal.get_journal_dashboard_datas()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 2)
self.assertIn('68.42', dashboard_data['sum_draft'])
@ -55,24 +59,42 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
# Check Both
invoice.action_post()
dashboard_data = journal.get_journal_dashboard_datas()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 1)
self.assertIn('-\N{ZERO WIDTH NO-BREAK SPACE}13.30', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 1)
self.assertIn('81.72', dashboard_data['sum_waiting'])
# Check partial on invoice
partial_payment = self.env['account.payment'].create({
'amount': 13.3,
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': self.partner_a.id,
})
partial_payment.action_post()
(invoice + partial_payment.move_id).line_ids.filtered(lambda line: line.account_type == 'asset_receivable').reconcile()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 1)
self.assertIn('13.3', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 1)
self.assertIn('68.42', dashboard_data['sum_waiting'])
# Check waiting payment
refund.action_post()
dashboard_data = journal.get_journal_dashboard_datas()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 0)
self.assertIn('0.00', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 2)
self.assertIn('68.42', dashboard_data['sum_waiting'])
self.assertIn('55.12', dashboard_data['sum_waiting'])
# Check partial
# Check partial on refund
payment = self.env['account.payment'].create({
'amount': 10.0,
'payment_type': 'outbound',
@ -85,53 +107,68 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
.filtered(lambda line: line.account_type == 'asset_receivable')\
.reconcile()
dashboard_data = journal.get_journal_dashboard_datas()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 0)
self.assertIn('0.00', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 2)
self.assertIn('78.42', dashboard_data['sum_waiting'])
self.assertIn('65.12', dashboard_data['sum_waiting'])
dashboard_data = journal.get_journal_dashboard_datas()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_late'], 2)
self.assertIn('78.42', dashboard_data['sum_late'])
self.assertIn('65.12', dashboard_data['sum_late'])
def test_sale_purchase_journal_for_multi_currency_purchase(self):
currency = self.currency_data['currency']
def test_sale_purchase_journal_for_purchase(self):
"""
Test different purchase journal setups with or without multicurrency:
1) Journal with no currency, bills in foreign currency -> dashboard data should be displayed in company currency
2) Journal in foreign currency, bills in foreign currency -> dashboard data should be displayed in foreign currency
3) Journal in foreign currency, bills in company currency -> dashboard data should be displayed in foreign currency
4) Journal in company currency, bills in company currency -> dashboard data should be displayed in company currency
5) Journal in company currency, bills in foreign currency -> dashboard data should be displayed in company currency
"""
# This test is defined in the account_3way_match module with different values, so we skip it when the module is installed
if self.env['ir.module.module'].search([('name', '=', 'account_3way_match')]).state == 'installed':
self.skipTest("This test won't work if account_3way_match is installed")
foreign_currency = self.other_currency
company_currency = self.company_data['currency']
invoice = self.env['account.move'].create({
'move_type': 'in_invoice',
'invoice_date': '2017-01-01',
'date': '2017-01-01',
'partner_id': self.partner_a.id,
'currency_id': currency.id,
'invoice_line_ids': [
(0, 0, {'name': 'test', 'price_unit': 200})
],
})
invoice.action_post()
setup_values = [
[self.company_data['default_journal_purchase'], foreign_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
]
payment = self.env['account.payment'].create({
'amount': 90.0,
'date': '2016-01-01',
'payment_type': 'outbound',
'partner_type': 'supplier',
'partner_id': self.partner_a.id,
'currency_id': currency.id,
})
payment.action_post()
expected_vals_list = [
# number_draft, sum_draft, number_waiting, sum_waiting, number_late, sum_late, currency
[ 1, 100, 1, 55, 1, 55, company_currency],
[ 1, 200, 1, 110, 1, 110, foreign_currency],
[ 1, 400, 1, 220, 1, 220, foreign_currency],
[ 1, 200, 1, 110, 1, 110, company_currency],
[ 1, 100, 1, 55, 1, 55, company_currency],
]
(invoice + payment.move_id).line_ids.filtered_domain([
('account_id', '=', self.company_data['default_account_payable'].id)
]).reconcile()
for (purchase_journal, bill_currency), expected_vals in zip(setup_values, expected_vals_list):
with self.subTest(purchase_journal_currency=purchase_journal.currency_id, bill_currency=bill_currency, expected_vals=expected_vals):
bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=True, amounts=[200], currency=bill_currency, journal=purchase_journal)
_draft_bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=False, amounts=[200], currency=bill_currency, journal=purchase_journal)
dashboard_data = self.company_data['default_journal_purchase'].get_journal_dashboard_datas()
self.assertEqual(format_amount(self.env, 70, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(format_amount(self.env, 70, company_currency), dashboard_data['sum_late'])
payment = self.init_payment(-90, post=True, date='2017-01-01', currency=bill_currency)
(bill + payment.move_id).line_ids.filtered_domain([
('account_id', '=', self.company_data['default_account_payable'].id)
]).reconcile()
self.assertDashboardPurchaseSaleData(purchase_journal, *expected_vals)
def test_sale_purchase_journal_for_multi_currency_sale(self):
currency = self.currency_data['currency']
# This test is defined in the account_3way_match module with different values, so we skip it when the module is installed
if self.env['ir.module.module'].search([('name', '=', 'account_3way_match')]).state == 'installed':
self.skipTest("This test won't work if account_3way_match is installed")
currency = self.other_currency
company_currency = self.company_data['currency']
invoice = self.env['account.move'].create({
@ -160,9 +197,28 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
('account_id', '=', self.company_data['default_account_receivable'].id)
]).reconcile()
dashboard_data = self.company_data['default_journal_sale'].get_journal_dashboard_datas()
self.assertEqual(format_amount(self.env, 70, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(format_amount(self.env, 70, company_currency), dashboard_data['sum_late'])
default_journal_sale = self.company_data['default_journal_sale']
dashboard_data = default_journal_sale._get_journal_dashboard_data_batched()[default_journal_sale.id]
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_late'])
@freeze_time("2023-03-15")
def test_purchase_journal_numbers_and_sums(self):
# This test is defined in the account_3way_match module with different values, so we skip it when the module is installed
if self.env['ir.module.module'].search([('name', '=', 'account_3way_match')]).state == 'installed':
self.skipTest("This test won't work if account_3way_match is installed")
company_currency = self.company_data['currency']
journal = self.company_data['default_journal_purchase']
self._create_test_vendor_bills(journal)
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
# Expected behavior is to have three moves waiting for payment for a total amount of 4440$ one of which would be late
# for a total amount of 40$ (second move has one of three lines late but that's not enough to make the move late)
self.assertEqual(3, dashboard_data['number_waiting'])
self.assertEqual(format_amount(self.env, 4440, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(1, dashboard_data['number_late'])
self.assertEqual(format_amount(self.env, 40, company_currency), dashboard_data['sum_late'])
def test_gap_in_sequence_warning(self):
journal = self.company_data['default_journal_sale']
@ -187,9 +243,9 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
self.assertFalse(journal._query_has_sequence_holes()) # no gap, no gap warning, and draft moves shouldn't trigger the warning
moves[2:4].button_draft()
self.assertFalse(journal._query_has_sequence_holes()) # no gap (with draft moves using sequence numbers), no gap warning
self.assertTrue(journal._query_has_sequence_holes()) # gap due to draft moves using sequence numbers, gap warning
moves[3].unlink()
self.assertTrue(journal.has_sequence_holes) # gap due to missing sequence, gap warning
self.assertTrue(journal._query_has_sequence_holes()) # gap due to missing sequence, gap warning
moves[2].action_post()
self.company_data['company'].write({'fiscalyear_lock_date': gap_date + relativedelta(days=1)})
@ -198,3 +254,153 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon):
moves[6].button_draft()
moves[6].button_cancel()
self.assertTrue(journal._query_has_sequence_holes()) # gap due to canceled move using a sequence, gap warning
def test_bank_journal_with_default_account_as_outstanding_account_payments(self):
"""
Test that payments are excluded from the miscellaneaous operations and are included in the balance
when having the default_account_id set as outstanding account on the journal
"""
bank_journal = self.company_data['default_journal_bank'].copy()
bank_journal.outbound_payment_method_line_ids[0].payment_account_id = bank_journal.default_account_id
bank_journal.inbound_payment_method_line_ids[0].payment_account_id = bank_journal.default_account_id
payment = self.env['account.payment'].create({
'amount': 100,
'payment_type': 'inbound',
'partner_type': 'customer',
'journal_id': bank_journal.id,
})
payment.action_post()
dashboard_data = bank_journal._get_journal_dashboard_data_batched()[bank_journal.id]
self.assertEqual(dashboard_data['nb_misc_operations'], 0)
self.assertEqual(dashboard_data['account_balance'], (bank_journal.currency_id or self.env.company.currency_id).format(100))
def test_bank_journal_different_currency(self):
"""Test that the misc operations amount on the dashboard is correct
for a bank account in another currency."""
foreign_currency = self.other_currency
bank_journal = self.company_data['default_journal_bank'].copy({'currency_id': foreign_currency.id})
self.assertNotEqual(bank_journal.currency_id, bank_journal.company_id.currency_id)
move = self.env['account.move'].create({
'journal_id': self.company_data['default_journal_misc'].id,
'line_ids': [
Command.create({
'account_id': bank_journal.default_account_id.id,
'currency_id': foreign_currency.id,
'amount_currency': 100,
}),
Command.create({
'account_id': self.company_data['default_account_assets'].id,
'currency_id': foreign_currency.id,
'amount_currency': -100,
})
]
})
move.action_post()
dashboard_data = bank_journal._get_journal_dashboard_data_batched()[bank_journal.id]
self.assertEqual(dashboard_data.get('misc_operations_balance', 0), foreign_currency.format(100))
bank_journal.default_account_id.currency_id = False # not a normal case
company_currency_move = self.env['account.move'].create({
'journal_id': self.company_data['default_journal_misc'].id,
'line_ids': [
Command.create({
'account_id': bank_journal.default_account_id.id,
'debit': 100,
}),
Command.create({
'account_id': self.company_data['default_account_assets'].id,
'credit': 100,
})
]
})
company_currency_move.action_post()
dashboard_data = bank_journal._get_journal_dashboard_data_batched()[bank_journal.id]
self.assertEqual(dashboard_data.get('misc_operations_balance', 0), None)
self.assertEqual(dashboard_data.get('misc_class', ''), 'text-warning')
def test_to_check_posted(self):
"""We want to only have the information on posted moves"""
journal = self.env['account.journal'].create({
'name': 'Test Foreign Currency Journal',
'type': 'sale',
'code': 'TEST',
'currency_id': self.currency.id,
'company_id': self.env.company.id,
})
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'checked': False,
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'price_unit': 100,
'tax_ids': [],
})
]
})
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['to_check_balance'], journal.currency_id.format(0))
move.action_post()
move.checked = False
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['to_check_balance'], journal.currency_id.format(100))
def test_to_check_amount_different_currency(self):
"""
We want the to_check amount to be displayed in the journal currency
Company currency = $
Journal's currency = €
Inv01 of 100 EUR; rate: 2/1$
Inv02 of 100 CHF; rate: 4CHF/1$
=> to check = 150
"""
self.env.ref('base.CHF').write({'active': True})
self.env['res.currency.rate'].create({
'currency_id': self.env.ref('base.EUR').id,
'name': '2024-12-01',
'rate': 2.0,
})
self.env['res.currency.rate'].create({
'currency_id': self.env.ref('base.CHF').id,
'name': '2024-12-01',
'rate': 4.0,
})
journal = self.env['account.journal'].create({
'name': 'Test Foreign Currency Journal',
'type': 'sale',
'code': 'TEST',
'currency_id': self.env.ref('base.EUR').id,
'company_id': self.env.company.id,
})
moves = self.env['account.move'].create([{
'move_type': 'out_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'currency_id': currency.id,
'checked': False,
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'price_unit': 100,
'tax_ids': [],
})
]
} for currency in (self.env.ref('base.EUR'), self.env.ref('base.CHF'))])
moves.action_post()
moves.checked = False
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['to_check_balance'], journal.currency_id.format(150))

View file

@ -0,0 +1,103 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo import Command
from odoo.tests import tagged
from odoo.tools.misc import format_amount
@tagged('post_install', '-at_install')
class TestAccountJournalDashboardCommon(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
def _create_test_vendor_bills(self, journal):
# Setup multiple payments term
twentyfive_now_term = self.env['account.payment.term'].create({
'name': '25% now, rest in 30 days',
'note': 'Pay 25% on invoice date and 75% 30 days later',
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 25.00,
'delay_type': 'days_after',
'nb_days': 0,
}),
Command.create({
'value': 'percent',
'value_amount': 75.00,
'delay_type': 'days_after',
'nb_days': 30,
}),
],
})
self.env['account.move'].create({
'move_type': 'in_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'invoice_date': '2023-04-01',
'date': '2023-03-15',
'invoice_payment_term_id': twentyfive_now_term.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'name': 'product test 1',
'price_unit': 4000,
'tax_ids': [],
})]
}).action_post()
# This bill has two residual amls. One of 1000$ and one of 3000$. Both are waiting for payment and due in 16 and 46 days.
# number_waiting += 1, sum_waiting += -4000$, number_late += 0, sum_late += 0$
self.env['account.move'].create({
'move_type': 'in_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'invoice_date': '2023-03-01',
'date': '2023-03-15',
'invoice_payment_term_id': twentyfive_now_term.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'name': 'product test 1',
'price_unit': 400,
'tax_ids': [],
})]
}).action_post()
# This bill has two residual amls. One of 100$ and one of 300$. One is late and due 14 days prior and one which is waiting for payment and due in 15 days.
# Even though one entry is late, the entire move isn't considered late since all entries are not.
# number_waiting += 1, sum_waiting += -400$, number_late += 0, sum_late += 0$
self.env['account.move'].create({
'move_type': 'in_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'invoice_date': '2023-02-01',
'date': '2023-03-15',
'invoice_payment_term_id': twentyfive_now_term.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'quantity': 1,
'name': 'product test 1',
'price_unit': 40,
'tax_ids': [],
})]
}).action_post()
# This bill has two residual amls. One of 10$ and one of 30$. Both of them are late and due 45 and 15 days prior.
# number_waiting += 1, sum_waiting += -40$, number_late += 1, sum_late += -40$
def assertDashboardPurchaseSaleData(self, journal, number_draft, sum_draft, number_waiting, sum_waiting, number_late, sum_late, currency, **kwargs):
expected_values = {
'number_draft': number_draft,
'sum_draft': format_amount(self.env, sum_draft, currency),
'number_waiting': number_waiting,
'sum_waiting': format_amount(self.env, sum_waiting, currency),
'number_late': number_late,
'sum_late': format_amount(self.env, sum_late, currency),
**kwargs
}
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertDictEqual({**dashboard_data, **expected_values}, dashboard_data)

View file

@ -0,0 +1,413 @@
from contextlib import closing
from datetime import timedelta
from freezegun import freeze_time
from odoo import Command, fields
from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import UserError
from odoo.tests import new_test_user, tagged
@tagged('post_install', '-at_install')
class TestAccountLockException(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.fakenow = cls.env.cr.now()
cls.startClassPatcher(freeze_time(cls.fakenow))
cls.other_user = new_test_user(
cls.env,
name='Other User',
login='other_user',
password='password',
email='other_user@example.com',
group_ids=cls.get_default_groups().ids,
company_id=cls.env.company.id,
)
cls.company_data_2 = cls.setup_other_company()
cls.soft_lock_date_info = [
('fiscalyear_lock_date', 'out_invoice'),
('tax_lock_date', 'out_invoice'),
('sale_lock_date', 'out_invoice'),
('purchase_lock_date', 'in_invoice'),
]
def test_user_exception_move_edit_multi_user(self):
"""
Test that an exception for a specific user only works for that user.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception to make the move editable (for the current user)
self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_move_edit_multi_user',
})
move.button_draft()
move.action_post()
# Check that the exception does not apply to other users
with self.assertRaises(UserError):
move.with_user(self.other_user).button_draft()
def test_global_exception_move_edit_multi_user(self):
"""
Test that an exception without a specified user works for any user.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add a global exception to make the move editable for everyone
self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': False,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_global_exception_move_edit_multi_user',
})
move.button_draft()
move.action_post()
move.with_user(self.other_user).button_draft()
def test_user_exception_branch(self):
"""
Test that the locking and exception mechanism works correctly in company hierarchies.
* A lock in the branch does not lock the parent.
* A lock in the parent also locks the branch.
* An exception in the branch does not matter for the lock in the parent.
* Let both parent and branch be locked.
To make changes in the locked period in the brranch we need exceptions in both companies.
"""
root_company = self.company_data['company']
root_company.write({'child_ids': [Command.create({'name': 'branch'})]})
self.cr.precommit.run() # load the CoA
branch = root_company.child_ids
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
# Create a move in the branch
branch_move = self.init_invoice(
move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a, company=branch,
)
# Create a move in the parent company
root_move = self.init_invoice(
move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a, company=root_company,
)
# Lock the branch
branch[lock_date_field] = fields.Date.to_date('2020-01-01')
# The branch_move is locked while the root_move is not
with self.assertRaises(UserError):
branch_move.button_draft()
root_move.button_draft()
root_move.action_post()
# Add an exception in the branch to make the branch_move editable (for the current user)
self.env['account.lock_exception'].create({
'company_id': branch.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_branch branch exception',
})
branch_move.button_draft()
branch_move.action_post()
# Lock the parent company
root_company[lock_date_field] = fields.Date.to_date('2020-01-01')
# Check that both moves are locked now (the branch exception alone is insufficient)
for move in [branch_move, root_move]:
with self.assertRaises(UserError):
move.button_draft()
# Add an exception in the parent company to make both moves editable (for the current user)
self.env['account.lock_exception'].create({
'company_id': root_company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_branch root_company exception',
})
for move in [branch_move, root_move]:
move.button_draft()
move.action_post()
def test_user_exception_wrong_company(self):
"""
Test that an exception only works for the specified company.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception for another company
self.env['account.lock_exception'].create({
'company_id': self.company_data_2['company'].id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_move_edit_multi_user',
})
# Check that the exception is insufficient
with self.assertRaises(UserError):
move.button_draft()
def test_user_exception_insufficient(self):
"""
Test that the exception only works if the specified lock date is actually before the accounting date.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception before the lock date but not before the date of the test_invoice
self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2016-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_move_edit_multi_user',
})
# Check that the exception is insufficient
with self.assertRaises(UserError):
move.button_draft()
def test_expired_exception(self):
"""
Test that the exception does not work if we are past the `end_datetime` of the exception.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an expired exception
self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'create_date': self.fakenow - timedelta(hours=24),
'end_datetime': self.fakenow - timedelta(seconds=1),
'reason': 'test_expired_exception',
})
with self.assertRaises(UserError):
move.button_draft()
def test_revoked_exception(self):
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception to make the move editable (for the current user)
exception = self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_move_edit_multi_user',
})
move.button_draft()
move.action_post()
exception.action_revoke()
# Check that the exception does not work anymore
with self.assertRaises(UserError):
move.button_draft()
def test_user_exception_wrong_field(self):
for lock_date_field, move_type, exception_lock_date_field in [
('fiscalyear_lock_date', 'out_invoice', 'tax_lock_date'),
('tax_lock_date', 'out_invoice', 'fiscalyear_lock_date'),
('sale_lock_date', 'out_invoice', 'purchase_lock_date'),
('purchase_lock_date', 'in_invoice', 'sale_lock_date'),
]:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception for a different lock date field
self.env['account.lock_exception'].create({
'company_id': self.company_data_2['company'].id,
'user_id': self.env.user.id,
exception_lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_wrong_field',
})
# Check that the exception is insufficient
with self.assertRaises(UserError):
move.button_draft()
def test_hard_lock_date(self):
"""
Test that
* exceptions (for other lock date fields) do not allow bypassing the hard lock date
* the hard lock date cannot be decreased or removed
"""
in_move = self.init_invoice('in_invoice', invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
out_move = self.init_invoice('out_invoice', invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
self.company.hard_lock_date = fields.Date.to_date('2020-01-01')
# Check that we cannot remove the hard lock date.
with self.assertRaises(UserError):
self.company.hard_lock_date = False
# Check that we cannot decrease the hard lock date.
with self.assertRaises(UserError):
self.company.hard_lock_date = fields.Date.to_date('2019-01-01')
# Create exceptions for all lock date fields except the hard lock date
self.env['account.lock_exception'].create([
{
'company_id': self.company_data_2['company'].id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': f'test_hard_lock_ignores_exceptions {lock_date_field}',
}
for lock_date_field in SOFT_LOCK_DATE_FIELDS
])
# Check that the exceptions are insufficient
for move in [in_move, out_move]:
with self.assertRaises(UserError):
move.button_draft()
def test_company_lock_date(self):
"""
Test the `company_lock_date` field is set corretly on exception creation.
Test the behavior when a company lock date is changed.
* Every active exception gets revoked and recreated with the new company lock date
* Non-active exceptions are not affected
"""
self.env['account.lock_exception'].search([]).sudo().unlink()
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
revoked_exception = self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_exception_recreated_on_lock_date_change revoked',
})
revoked_exception.action_revoke()
active_exception = self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_exception_recreated_on_lock_date_change active',
})
# Check that the company lock date field was set correcyly on exception creation
self.assertEqual(revoked_exception.company_lock_date, fields.Date.to_date('2020-01-01'))
self.assertEqual(active_exception.company_lock_date, fields.Date.to_date('2020-01-01'))
# The lock date change should trigger the "recreation" proces
self.company[lock_date_field] = fields.Date.to_date('2021-01-01')
self.assertEqual(revoked_exception.company_lock_date, fields.Date.to_date('2020-01-01'))
self.assertEqual(active_exception.state, 'revoked')
exceptions = self.env['account.lock_exception'].with_context(active_test=False).search([])
self.assertEqual(len(exceptions), 3)
new_exception = exceptions - revoked_exception - active_exception
# Check that the new exception is a "recreation" of the `active_exception`
self.assertRecordValues(new_exception, [{
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: fields.Date.to_date('2010-01-01'),
'company_lock_date': fields.Date.to_date('2021-01-01'),
'end_datetime': self.env.cr.now() + timedelta(hours=24),
'reason': 'test_exception_recreated_on_lock_date_change active',
}])
def test_user_exception_remove_lock_date(self):
"""
Test that an exception removing a lock date (instead of just decreasing it) works.
"""
for lock_date_field, move_type in self.soft_lock_date_info:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type), closing(self.cr.savepoint()):
move = self.init_invoice(move_type, invoice_date='2016-01-01', post=True, amounts=[1000.0], taxes=self.tax_sale_a)
# Lock the move
self.company[lock_date_field] = fields.Date.to_date('2020-01-01')
with self.assertRaises(UserError):
move.button_draft()
# Add an exception removing the lock date
self.env['account.lock_exception'].create({
'company_id': self.company.id,
'user_id': self.env.user.id,
lock_date_field: False,
'end_datetime': self.fakenow + timedelta(hours=24),
'reason': 'test_user_exception_move_edit_multi_user',
})
move.button_draft()

View file

@ -0,0 +1,284 @@
from odoo import Command
from odoo.addons.account.tests.common import TestAccountMergeCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMergeWizard(TestAccountMergeCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_data_2 = cls.setup_other_company()
cls.company_1 = cls.company_data['company']
cls.company_2 = cls.company_data_2['company']
cls.accounts = cls.env['account.account']._load_records([
{
'xml_id': f'account.{cls.company_1.id}_test_account_1',
'values': {
'name': 'My First Account',
'code': '100234',
'account_type': 'asset_receivable',
'company_ids': [Command.link(cls.company_1.id)],
'tax_ids': [Command.link(cls.company_data['default_tax_sale'].id)],
'tag_ids': [Command.link(cls.env.ref('account.account_tag_operating').id)],
},
},
{
'xml_id': f'account.{cls.company_1.id}_test_account_2',
'values': {
'name': 'My Second Account',
'code': '100235',
'account_type': 'liability_payable',
'company_ids': [Command.link(cls.company_1.id)],
'tax_ids': [Command.link(cls.company_data['default_tax_sale'].id)],
'tag_ids': [Command.link(cls.env.ref('account.account_tag_operating').id)],
},
},
{
'xml_id': f'account.{cls.company_2.id}_test_account_3',
'values': {
'name': 'My Third Account',
'code': '100236',
'account_type': 'asset_receivable',
'company_ids': [Command.link(cls.company_2.id)],
'tax_ids': [Command.link(cls.company_data_2['default_tax_sale'].id)],
'tag_ids': [Command.link(cls.env.ref('account.account_tag_investing').id)],
},
},
{
'xml_id': f'account.{cls.company_2.id}_test_account_4',
'values': {
'name': 'My Fourth Account',
'code': '100237',
'account_type': 'liability_payable',
'company_ids': [Command.link(cls.company_2.id)],
'tax_ids': [Command.link(cls.company_data_2['default_tax_sale'].id)],
'tag_ids': [Command.link(cls.env.ref('account.account_tag_investing').id)],
},
}
])
def _create_hashed_move(self, account, company_data):
hashed_move = self.env['account.move'].create([
{
'journal_id': company_data['default_journal_sale'].id,
'date': '2024-07-20',
'line_ids': [
Command.create({
'account_id': account.id,
'balance': 10.0,
}),
Command.create({
'account_id': company_data['default_account_receivable'].id,
'balance': -10.0,
})
]
},
])
hashed_move.action_post()
company_data['default_journal_sale'].restrict_mode_hash_table = True
hashed_move.button_hash()
def test_merge(self):
""" Check that you can merge accounts. """
# 1. Set-up various fields pointing to the accounts to merge
referencing_records = {
account: self._create_references_to_account(account)
for account in self.accounts
}
# Also set up different names for the accounts in various languages
self.env['res.lang']._activate_lang('fr_FR')
self.env['res.lang']._activate_lang('nl_NL')
self.accounts[0].with_context({'lang': 'fr_FR'}).name = "Mon premier compte"
self.accounts[2].with_context({'lang': 'fr_FR'}).name = "Mon troisième compte"
self.accounts[2].with_context({'lang': 'nl_NL'}).name = "Mijn derde conto"
# 2. Check that the merge wizard groups accounts 1 and 3 together, and accounts 2 and 4 together.
wizard = self._create_account_merge_wizard(self.accounts)
expected_wizard_line_vals = [
{
'display_type': 'line_section',
'account_id': self.accounts[0].id,
'info': 'Trade Receivable (Reconcilable)',
},
{
'display_type': 'account',
'account_id': self.accounts[0].id,
'info': False,
},
{
'display_type': 'account',
'account_id': self.accounts[2].id,
'info': False,
},
{
'display_type': 'line_section',
'account_id': self.accounts[1].id,
'info': 'Trade Payable (Reconcilable)',
},
{
'display_type': 'account',
'account_id': self.accounts[1].id,
'info': False,
},
{
'display_type': 'account',
'account_id': self.accounts[3].id,
'info': False,
},
]
self.assertRecordValues(wizard.wizard_line_ids, expected_wizard_line_vals)
# 3. Perform the merge
wizard.action_merge()
# 4. Check that the accounts other than the ones to merge into are deleted.
self.assertFalse(self.accounts[2:].exists())
# 5. Check that the company_ids and codes are correctly merged.
self.assertRecordValues(
self.accounts[:2],
[
{
'company_ids': [self.company_1.id, self.company_2.id],
'name': 'My First Account',
'code': '100234',
'tax_ids': [self.company_data['default_tax_sale'].id, self.company_data_2['default_tax_sale'].id],
'tag_ids': [self.env.ref('account.account_tag_operating').id, self.env.ref('account.account_tag_investing').id],
},
{
'company_ids': [self.company_1.id, self.company_2.id],
'name': 'My Second Account',
'code': '100235',
'tax_ids': [self.company_data['default_tax_sale'].id, self.company_data_2['default_tax_sale'].id],
'tag_ids': [self.env.ref('account.account_tag_operating').id, self.env.ref('account.account_tag_investing').id],
}
]
)
self.assertRecordValues(
self.accounts[:2].with_company(self.company_2),
[{'code': '100236'}, {'code': '100237'}]
)
# 6. Check that references to the accounts are merged correctly
merged_account_by_account = {
self.accounts[0]: self.accounts[0],
self.accounts[1]: self.accounts[1],
self.accounts[2]: self.accounts[0],
self.accounts[3]: self.accounts[1],
}
for account, referencing_records_for_account in referencing_records.items():
expected_account = merged_account_by_account[account]
for referencing_record, fname in referencing_records_for_account.items():
expected_field_value = expected_account.ids if referencing_record._fields[fname].type == 'many2many' else expected_account.id
self.assertRecordValues(referencing_record, [{fname: expected_field_value}])
# 7. Check that the xmlids are preserved
self.assertEqual(self.env['account.chart.template'].ref('test_account_1'), self.accounts[0])
self.assertEqual(self.env['account.chart.template'].ref('test_account_2'), self.accounts[1])
self.assertEqual(self.env['account.chart.template'].with_company(self.company_2).ref('test_account_3'), self.accounts[0])
self.assertEqual(self.env['account.chart.template'].with_company(self.company_2).ref('test_account_4'), self.accounts[1])
# 8. Check that the name translations are merged correctly
self.assertRecordValues(self.accounts[0].with_context({'lang': 'fr_FR'}), [{'name': "Mon premier compte"}])
self.assertRecordValues(self.accounts[0].with_context({'lang': 'nl_NL'}), [{'name': "Mijn derde conto"}])
def test_cannot_merge_same_company(self):
""" Check that you cannot merge two accounts belonging to the same company. """
self.accounts[1].account_type = 'asset_receivable'
wizard = self._create_account_merge_wizard(self.accounts[:2])
expected_wizard_line_vals = [
{
'display_type': 'line_section',
'account_id': self.accounts[0].id,
'info': 'Trade Receivable (Reconcilable)',
},
{
'display_type': 'account',
'account_id': self.accounts[0].id,
'info': False,
},
{
'display_type': 'account',
'account_id': self.accounts[1].id,
'info': "Belongs to the same company as 100234 My First Account.",
},
]
self.assertRecordValues(wizard.wizard_line_ids, expected_wizard_line_vals)
def test_can_merge_accounts_if_one_is_hashed(self):
""" Check that you can merge two accounts if only one is hashed, but that the hashed account's ID is preserved. """
# 1. Create hashed move and check that the wizard has no errors
self._create_hashed_move(self.accounts[2], self.company_data_2)
wizard = self._create_account_merge_wizard(self.accounts[0] | self.accounts[2])
expected_wizard_line_vals = [
{
'display_type': 'line_section',
'account_id': self.accounts[0].id,
'info': 'Trade Receivable (Reconcilable)',
},
{
'display_type': 'account',
'account_id': self.accounts[0].id,
'info': False,
},
{
'display_type': 'account',
'account_id': self.accounts[2].id,
'info': False,
},
]
self.assertRecordValues(wizard.wizard_line_ids, expected_wizard_line_vals)
# 2. Perform the merge
wizard.action_merge()
# 3. Check that the non-hashed account is deleted.
self.assertFalse(self.accounts[0].exists())
def test_cannot_merge_two_hashed_accounts(self):
""" Check that you cannot merge two accounts if both are hashed. """
self._create_hashed_move(self.accounts[0], self.company_data)
self._create_hashed_move(self.accounts[2], self.company_data_2)
wizard = self._create_account_merge_wizard(self.accounts[0] | self.accounts[2])
expected_wizard_line_vals = [
{
'display_type': 'line_section',
'account_id': self.accounts[0].id,
'info': 'Trade Receivable (Reconcilable)',
},
{
'display_type': 'account',
'account_id': self.accounts[0].id,
'info': False,
},
{
'display_type': 'account',
'account_id': self.accounts[2].id,
'info': "Contains hashed entries, but 100234 My First Account also has hashed entries.",
},
]
self.assertRecordValues(wizard.wizard_line_ids, expected_wizard_line_vals)
def test_merge_accounts_company_dependent_related(self):
payable_accounts = self.env['account.account'].search([('name', '=', 'Account Payable')])
self.assertEqual(len(payable_accounts), 2)
wizard = self._create_account_merge_wizard(payable_accounts)
wizard.action_merge()
payable_accounts = self.env['account.account'].search([('name', '=', 'Account Payable')])
self.assertEqual(len(payable_accounts), 1)
for company in self.env.companies:
partner_payable_account = self.partner_a.with_company(company).property_account_payable_id.exists()
self.assertEqual(partner_payable_account, payable_accounts)

View file

@ -0,0 +1,25 @@
from odoo import http
from odoo.tests import tagged, HttpCase
@tagged("-at_install", "post_install")
class TestAccountMoveAttachment(HttpCase):
def test_preserving_manually_added_attachments(self):
""" Preserve attachments manually added (not coming from emails) to an invoice """
self.authenticate("admin", "admin")
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
})
self.assertFalse(invoice.attachment_ids)
response = self.url_open("/mail/attachment/upload",
{
"csrf_token": http.Request.csrf_token(self),
"thread_id": invoice.id,
"thread_model": "account.move",
},
files={'ufile': ('salut.txt', b"Salut !\n", 'text/plain')},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(invoice.attachment_ids)

View file

@ -1,14 +1,21 @@
# -*- coding: utf-8 -*-
from contextlib import closing
import freezegun
from odoo import fields, Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
import freezegun
@tagged('post_install', '-at_install')
class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
@ -32,17 +39,18 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
})
def _create_payment(self, date, **kwargs):
return self.env['account.payment'].create({
payment = self.env['account.payment'].create({
'partner_id': self.partner_a.id,
'payment_type': 'inbound',
'partner_type': 'customer',
**kwargs,
'date': date,
})
payment.action_post()
return payment
def _set_lock_date(self, lock_date, period_lock_date=None):
def _set_lock_date(self, lock_date):
self.env.company.fiscalyear_lock_date = fields.Date.from_string(lock_date)
self.env.company.period_lock_date = fields.Date.from_string(period_lock_date)
def _reverse_invoice(self, invoice):
move_reversal = self.env['account.move.reversal']\
@ -50,9 +58,8 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
.create({
'journal_id': invoice.journal_id.id,
'reason': "no reason",
'refund_method': 'cancel',
})
reversal = move_reversal.reverse_moves()
reversal = move_reversal.refund_moves()
return self.env['account.move'].browse(reversal['res_id'])
# -------------------------------------------------------------------------
@ -147,31 +154,30 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
@freezegun.freeze_time('2017-02-12')
def test_reconcile_with_lock_date(self):
invoice = self._create_invoice('out_invoice', '2016-01-01', currency_id=self.currency_data['currency'].id)
refund = self._create_invoice('out_refund', '2017-01-01', currency_id=self.currency_data['currency'].id)
invoice = self._create_invoice('out_invoice', '2016-01-01', currency_id=self.other_currency.id)
refund = self._create_invoice('out_refund', '2017-01-01', currency_id=self.other_currency.id)
(invoice + refund).action_post()
self._set_lock_date('2017-01-31')
res = (invoice + refund).line_ids\
.filtered(lambda x: x.account_id.account_type == 'asset_receivable')\
.reconcile()
exchange_move = res['partials'].exchange_move_id
amls = (invoice + refund).line_ids.filtered(lambda x: x.account_id.account_type == 'asset_receivable')
amls.reconcile()
exchange_move = amls.matched_debit_ids.exchange_move_id
self.assertRecordValues(exchange_move, [{
'date': fields.Date.from_string('2017-02-01'),
'date': fields.Date.from_string('2017-02-12'),
'amount_total_signed': 200.0,
}])
@freezegun.freeze_time('2017-02-12')
def test_unreconcile_with_lock_date(self):
invoice = self._create_invoice('out_invoice', '2016-01-01', currency_id=self.currency_data['currency'].id)
refund = self._create_invoice('out_refund', '2017-01-01', currency_id=self.currency_data['currency'].id)
invoice = self._create_invoice('out_invoice', '2016-01-01', currency_id=self.other_currency.id)
refund = self._create_invoice('out_refund', '2017-01-01', currency_id=self.other_currency.id)
(invoice + refund).action_post()
res = (invoice + refund).line_ids\
.filtered(lambda x: x.account_id.account_type == 'asset_receivable')\
.reconcile()
exchange_move = res['partials'].exchange_move_id
amls = (invoice + refund).line_ids.filtered(lambda x: x.account_id.account_type == 'asset_receivable')
amls.reconcile()
exchange_move = amls.matched_debit_ids.exchange_move_id
self.assertEqual(exchange_move.state, 'posted')
self._set_lock_date('2017-01-31')
(invoice + refund).line_ids.remove_move_reconcile()
@ -201,11 +207,11 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
invoice = self._create_invoice(
'out_invoice', '2016-01-01',
currency_id=self.currency_data['currency'].id,
currency_id=self.other_currency.id,
invoice_line_ids=[{'tax_ids': [Command.set(tax.ids)]}],
)
payment = self._create_payment('2016-02-01', amount=invoice.amount_total)
(invoice + payment.move_id).action_post()
invoice.action_post()
self._set_lock_date('2017-01-03')
@ -236,9 +242,9 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
@freezegun.freeze_time('2023-05-01')
def test_caba_with_different_lock_dates(self):
"""
Test the date of the CABA move when reconciling a payment with an invoice
with date before fiscalyear_period but after period_lock_date either when
having accountant rights or not.
Test the date of the CABA move when reconciling a payment in case the lock dates
are different between post and reconciliation time (caba move creation time).
Ensure that user groups (accountant rights) do not matter.
"""
self.env.company.tax_exigibility = True
@ -256,30 +262,54 @@ class TestAccountMoveDateAlgorithm(AccountTestInvoicingCommon):
'cash_basis_transition_account_id': tax_waiting_account.id,
})
self._set_lock_date('2023-01-01', '2023-02-01')
for group, expected_date in (
('account.group_account_manager', '2023-01-30'),
('account.group_account_invoice', '2023-05-01'),
# User groups do not matter
for group in (
'account.group_account_manager',
'account.group_account_invoice',
):
with self.subTest(group=group, expected_date=expected_date):
self.env.user.groups_id = [Command.set(self.env.ref(group).ids)]
with self.subTest(group=group), closing(self.cr.savepoint()):
self.env.user.group_ids = [Command.set(self.env.ref(group).ids)]
self.assertTrue(self.env.user.user_has_groups(group))
self.assertTrue(self.env.user.has_group(group))
invoice = self._create_invoice(
'out_invoice', '2023-01-02',
invoice_line_ids=[{'tax_ids': [Command.set(tax.ids)]}],
)
payment = self._create_payment('2023-01-30', amount=invoice.amount_total)
(invoice + payment.move_id).action_post()
self.env.company.sudo().sale_lock_date = fields.Date.to_date('2023-02-01')
invoice.action_post()
self.assertEqual(invoice.date.isoformat(), '2023-02-28')
self.assertEqual(payment.move_id.date.isoformat(), '2023-01-30')
self.env.company.sudo().sale_lock_date = fields.Date.to_date('2023-03-01')
(invoice + payment.move_id).line_ids\
.filtered(lambda x: x.account_id.account_type == 'asset_receivable')\
.reconcile()
caba_move = self.env['account.move'].search([('tax_cash_basis_origin_move_id', '=', invoice.id)])
self.assertRecordValues(caba_move, [{
'date': fields.Date.from_string(expected_date),
}])
# The sale lock date does not matter for the caba move, since it is not in a sale journal
self.assertEqual(caba_move.journal_id.type, 'general')
self.assertEqual(caba_move.date.isoformat(), '2023-02-28')
@freezegun.freeze_time('2024-08-05')
def test_lock_date_exceptions(self):
for lock_date_field, move_type in [
('fiscalyear_lock_date', 'out_invoice'),
('tax_lock_date', 'out_invoice'),
('sale_lock_date', 'out_invoice'),
('purchase_lock_date', 'in_invoice'),
]:
with self.subTest(lock_date_field=lock_date_field, move_type=move_type):
self.env.company[lock_date_field] = '2024-07-31'
self.env['account.lock_exception'].create({
lock_date_field: fields.Date.to_date('2024-01-01'),
'end_datetime': False,
})
move = self.init_invoice(
move_type, amounts=[100], taxes=self.env.company.account_sale_tax_id,
invoice_date='2024-07-01', post=True
)
self.assertEqual(move.date, fields.Date.to_date('2024-07-01'))

View file

@ -0,0 +1,146 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import Form, tagged
@tagged('post_install', '-at_install')
class TestAccountMoveDuplicate(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.invoice = cls.init_invoice('in_invoice', products=cls.product_a + cls.product_b)
def test_in_invoice_single_duplicate_reference(self):
""" Ensure duplicated ref are computed correctly in this simple case (in_invoice)"""
bill_1 = self.invoice
bill_1.ref = 'a unique supplier reference that will be copied'
bill_2 = bill_1.copy(default={'invoice_date': bill_1.invoice_date})
# ensure no Error is raised
bill_2.ref = bill_1.ref
self.assertRecordValues(bill_2, [{'duplicated_ref_ids': bill_1.ids}])
def test_out_invoice_single_duplicate_reference(self):
"""
Ensure duplicated move are computed correctly in this simple case (out_invoice).
For it to be a duplicate, the partner, the invoice date and the amount total must be the same.
"""
invoice_1 = self.init_invoice(
move_type='out_invoice',
products=self.product_a,
invoice_date='2023-01-01'
)
invoice_2 = invoice_1.copy(default={'invoice_date': invoice_1.invoice_date})
self.assertRecordValues(invoice_2, [{'duplicated_ref_ids': invoice_1.ids}])
# Different date but same product and same partner, no duplicate
invoice_3 = invoice_1.copy(default={'invoice_date': '2023-12-31'})
self.assertRecordValues(invoice_3, [{'duplicated_ref_ids': []}])
# Different product and same partner and same date, no duplicate
invoice_4 = invoice_1 = self.init_invoice(
move_type='out_invoice',
products=self.product_b,
invoice_date='2023-01-01'
)
self.assertRecordValues(invoice_4, [{'duplicated_ref_ids': []}])
def test_in_invoice_single_duplicate_reference_with_form(self):
""" Ensure duplicated ref are computed correctly with UI's NEW_ID"""
invoice_1 = self.invoice
invoice_1.ref = 'a unique supplier reference that will be copied'
move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice'))
move_form.partner_id = self.partner_a
move_form.invoice_date = invoice_1.invoice_date
move_form.ref = invoice_1.ref
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.product_a
with move_form.invoice_line_ids.new() as line_form:
line_form.product_id = self.product_b
invoice_2 = move_form.save()
self.assertRecordValues(invoice_2, [{'duplicated_ref_ids': invoice_1.ids}])
def test_in_invoice_multiple_duplicate_reference_batch(self):
""" Ensure duplicated ref are computed correctly even when updated in batch"""
invoice_1 = self.invoice
invoice_1.ref = 'a unique supplier reference that will be copied'
invoice_2 = invoice_1.copy(default={'invoice_date': invoice_1.invoice_date})
invoice_3 = invoice_1.copy(default={'invoice_date': invoice_1.invoice_date})
# reassign to trigger the compute method
invoices = invoice_1 + invoice_2 + invoice_3
invoices.ref = invoice_1.ref
self.assertRecordValues(invoices, [
{'duplicated_ref_ids': (invoice_2 + invoice_3).ids},
{'duplicated_ref_ids': (invoice_1 + invoice_3).ids},
{'duplicated_ref_ids': (invoice_1 + invoice_2).ids},
])
def test_in_invoice_multiple_duplicate_reference_batch_in_edit_mode(self):
"""
Ensure duplicated ref are computed correctly even when updated in batch
when they are in edit mode
"""
invoice_1 = self.invoice
invoice_1.ref = 'a unique supplier reference that will be copied'
invoice_2 = invoice_1.copy(default={'invoice_date': invoice_1.invoice_date})
invoices_new = self.env['account.move'].browse([
self.env['account.move'].new(origin=inv).id for inv in (invoice_1, invoice_2)
])
# reassign to trigger the compute method
invoices_new.ref = invoice_1.ref
self.assertRecordValues(invoices_new, [
{'duplicated_ref_ids': (invoices_new[1]).ids},
{'duplicated_ref_ids': (invoices_new[0]).ids},
])
def test_in_invoice_single_duplicate_reference_diff_date(self):
""" Ensure duplicated ref are computed correctly for different dates"""
bill1 = self.invoice.copy({'invoice_date': self.invoice.invoice_date})
bill1.ref = 'bill1'
# Same ref but different year -> Not duplicated
bill2 = bill1.copy({'invoice_date': '2020-01-01'})
bill2.ref = bill1.ref
self.assertNotIn(bill1, bill2.duplicated_ref_ids)
self.assertNotIn(bill2, bill1.duplicated_ref_ids)
# Same ref and same year -> Duplicated
bill3 = bill1.copy({'invoice_date': f"{bill1.invoice_date.year}-04-11"})
bill3.ref = bill1.ref
self.assertEqual(bill3.duplicated_ref_ids, bill1)
# Even after posting
bill3.action_post()
self.assertEqual(bill3.duplicated_ref_ids, bill1)
# Same ref and no invoice date -> Duplicated
bill4 = self.invoice.copy()
bill4.ref = "bill4"
bill5 = bill4.copy()
bill5.ref = bill4.ref
self.assertEqual(bill5.duplicated_ref_ids, bill4)
def test_in_invoice_single_duplicate_no_reference(self):
""" Ensure duplicated bills are recognized with or without
a reference when the amount, date, partner are equal"""
bill1 = self.invoice.copy({'invoice_date': '2020-01-01'})
bill2 = bill1.copy()
# trigger compute method
all_bills = bill1 + bill2
all_bills.invoice_date = self.invoice.invoice_date
# Assert duplicates when there is no ref
self.assertIn(bill1, bill2.duplicated_ref_ids)
self.assertIn(bill2, bill1.duplicated_ref_ids)
# Assert duplicates when there is one ref
bill1.update({'ref': "bill1 ref"})
bill2.update({'ref': bill2.ref})
self.assertIn(bill1, bill2.duplicated_ref_ids)
self.assertIn(bill2, bill1.duplicated_ref_ids)
# Assert duplicated when different refs
bill1.update({'ref': bill1.ref})
bill2.update({'ref': "bill2 ref"})
self.assertIn(bill1, bill2.duplicated_ref_ids)
self.assertIn(bill2, bill1.duplicated_ref_ids)

View file

@ -0,0 +1,30 @@
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestAccountMoveImportTemplate(TransactionCase):
def setUp(self):
super().setUp()
self.AccountMove = self.env['account.move']
def fetch_template_for_type(self, move_type):
return self.AccountMove.with_context(default_move_type=move_type).get_import_templates()
def test_import_template(self):
def test_template(move_type, file_name):
template = self.fetch_template_for_type(move_type)
self.assertEqual(len(template), 1)
self.assertEqual(template[0].get('template'), file_name)
test_template('entry', '/account/static/xls/misc_operations_import_template.xlsx')
test_template('out_invoice', '/account/static/xls/customer_invoices_credit_notes_import_template.xlsx')
test_template('out_refund', '/account/static/xls/customer_invoices_credit_notes_import_template.xlsx')
test_template('in_invoice', '/account/static/xls/vendor_bills_refunds_import_template.xlsx')
test_template('in_refund', '/account/static/xls/vendor_bills_refunds_import_template.xlsx')
template = self.fetch_template_for_type('unknown_type')
self.assertEqual(template, [])
template = self.fetch_template_for_type(None)
self.assertEqual(template, [])

View file

@ -3,8 +3,7 @@
from lxml import etree
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests.common import Form
from odoo.tests import tagged
from odoo.tests import Form, tagged
from odoo import fields, Command
from collections import defaultdict
@ -14,13 +13,14 @@ from collections import defaultdict
class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
cls.invoice = cls.init_invoice('in_refund', products=cls.product_a+cls.product_b)
cls.product_line_vals_1 = {
'name': cls.product_a.name,
'product_id': cls.product_a.id,
'account_id': cls.product_a.property_account_expense_id.id,
'partner_id': cls.partner_a.id,
@ -39,7 +39,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'date_maturity': False,
}
cls.product_line_vals_2 = {
'name': cls.product_b.name,
'name': 'product_b',
'product_id': cls.product_b.id,
'account_id': cls.product_b.property_account_expense_id.id,
'partner_id': cls.partner_a.id,
@ -96,7 +96,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'date_maturity': False,
}
cls.term_line_vals_1 = {
'name': '',
'name': False,
'product_id': False,
'account_id': cls.company_data['default_account_payable'].id,
'partner_id': cls.partner_a.id,
@ -120,13 +120,16 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'journal_id': cls.company_data['default_journal_purchase'].id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'fiscal_position_id': False,
'payment_reference': '',
'payment_reference': False,
'invoice_payment_term_id': cls.pay_terms_a.id,
'amount_untaxed': 960.0,
'amount_tax': 168.0,
'amount_total': 1128.0,
}
(cls.tax_armageddon + cls.tax_armageddon.children_tax_ids).write({'type_tax_use': 'purchase'})
@classmethod
def setup_armageddon_tax(cls, tax_name, company_data, **kwargs):
return super().setup_armageddon_tax(tax_name, company_data, type_tax_use='purchase', **kwargs)
def setUp(self):
super(TestAccountMoveInRefundOnchanges, self).setUp()
@ -147,7 +150,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'name': self.product_b.name,
'name': 'product_b',
'product_id': self.product_b.id,
'product_uom_id': self.product_b.uom_id.id,
'account_id': self.product_b.property_account_expense_id.id,
@ -266,7 +269,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'turlututu installment #1',
'partner_id': self.partner_b.id,
'account_id': self.partner_b.property_account_payable_id.id,
'amount_currency': 338.4,
@ -274,7 +277,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'turlututu installment #2',
'partner_id': self.partner_b.id,
'account_id': self.partner_b.property_account_payable_id.id,
'amount_currency': 789.6,
@ -323,7 +326,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'turlututu installment #1',
'account_id': self.partner_b.property_account_payable_id.id,
'partner_id': self.partner_b.id,
'amount_currency': 331.2,
@ -331,7 +334,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'turlututu installment #2',
'account_id': self.partner_b.property_account_payable_id.id,
'partner_id': self.partner_b.id,
'amount_currency': 772.8,
@ -444,7 +447,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
def test_in_refund_line_onchange_cash_rounding_1(self):
# Required for `invoice_cash_rounding_id` to be visible in the view
self.env.user.groups_id += self.env.ref('account.group_cash_rounding')
self.env.user.group_ids += self.env.ref('account.group_cash_rounding')
# Test 'add_invoice_line' rounding
move_form = Form(self.invoice)
# Add a cash rounding having 'add_invoice_line'.
@ -602,44 +605,46 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
})
def test_in_refund_line_onchange_currency_1(self):
self.other_currency.rounding = 0.001
move_form = Form(self.invoice)
move_form.currency_id = self.currency_data['currency']
move_form.currency_id = self.other_currency
move_form.save()
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -800.0,
'credit': 400.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'credit': 80.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -144.0,
'credit': 72.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'credit': 12.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1128.0,
'debit': 564.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
# Change the date to get another rate: 1/3 instead of 1/2.
@ -650,38 +655,38 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -800.0,
'credit': 266.67,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'credit': 53.33,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -144.0,
'credit': 48.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'credit': 8.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1128.0,
'debit': 376.0,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'date': fields.Date.from_string('2016-01-01'),
'invoice_date': fields.Date.from_string('2016-01-01'),
})
@ -698,41 +703,41 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
{
**self.product_line_vals_1,
'quantity': 0.1,
'price_unit': 0.05,
'price_unit': 0.045,
'price_subtotal': 0.005,
'price_total': 0.006,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -0.005,
'credit': 0.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'credit': 53.33,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.001,
'credit': 8.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'credit': 8.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 208.006,
'debit': 69.33,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'date': fields.Date.from_string('2016-01-01'),
'invoice_date': fields.Date.from_string('2016-01-01'),
'amount_untaxed': 160.005,
@ -749,11 +754,11 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
{
**self.product_line_vals_1,
'quantity': 0.1,
'price_unit': 0.05,
'price_subtotal': 0.01,
'price_total': 0.01,
'amount_currency': -0.01,
'credit': 0.01,
'price_unit': 0.045,
'price_subtotal': 0.0,
'price_total': 0.0,
'amount_currency': -0.0,
'credit': 0.0,
},
self.product_line_vals_2,
{
@ -764,8 +769,8 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.tax_line_vals_2,
{
**self.term_line_vals_1,
'amount_currency': 208.01,
'debit': 208.01,
'amount_currency': 208.0,
'debit': 208.0,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
@ -773,9 +778,9 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'currency_id': self.company_data['currency'].id,
'date': fields.Date.from_string('2016-01-01'),
'invoice_date': fields.Date.from_string('2016-01-01'),
'amount_untaxed': 160.01,
'amount_untaxed': 160.0,
'amount_tax': 48.0,
'amount_total': 208.01,
'amount_total': 208.0,
})
def test_in_refund_onchange_past_invoice_1(self):
@ -784,15 +789,15 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
# `purchase` adds a view which makes `invoice_vendor_bill_id` invisible
# for purchase users
# https://github.com/odoo/odoo/blob/385884afd31f25d61e99d139ecd4c574d99a1863/addons/purchase/views/account_move_views.xml#L26
self.env.user.groups_id -= self.env.ref('purchase.group_purchase_manager')
self.env.user.groups_id -= self.env.ref('purchase.group_purchase_user')
# 'invisible': ['|', ('state', '!=', 'draft'), ('move_type', '!=', 'in_invoice')]
self.env.user.group_ids -= self.env.ref('purchase.group_purchase_manager')
self.env.user.group_ids -= self.env.ref('purchase.group_purchase_user')
# invisible="state != 'draft' or move_type != 'in_invoice'"
# This is an in_refund invoice, `invoice_vendor_bill_id` is not supposed to be visible
# and therefore not supposed to be changed.
view = self.env.ref('account.view_move_form')
tree = etree.fromstring(view.arch)
for node in tree.xpath('//field[@name="invoice_vendor_bill_id"]'):
del node.attrib['attrs']
del node.attrib['invisible']
view.arch = etree.tostring(tree)
move_form = Form(self.invoice)
@ -815,7 +820,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'in_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -836,37 +841,37 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -800.0,
'credit': 400.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'credit': 80.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -144.0,
'credit': 72.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'credit': 12.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1128.0,
'debit': 564.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_in_refund_write_1(self):
@ -875,7 +880,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'in_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -900,37 +905,37 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -800.0,
'credit': 400.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'credit': 80.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -144.0,
'credit': 72.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'credit': 12.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1128.0,
'debit': 564.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_in_refund_create_storno(self):
@ -942,7 +947,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'in_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -963,7 +968,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -800.0,
'balance': -400.0,
'debit': -400.0,
@ -971,7 +976,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -160.0,
'balance': -80.0,
'debit': -80.0,
@ -979,7 +984,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -144.0,
'balance': -72.0,
'debit': -72.0,
@ -987,7 +992,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -24.0,
'balance': -12.0,
'debit': -12.0,
@ -995,7 +1000,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1128.0,
'balance': 564.0,
'debit': 0.0,
@ -1003,7 +1008,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_in_refund_reverse_caba(self):
@ -1012,19 +1017,16 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'code': 'TWAIT',
'account_type': 'liability_current',
'reconcile': True,
'company_id': self.company_data['company'].id,
})
tax_final_account = self.env['account.account'].create({
'name': 'TAX_TO_DEDUCT',
'code': 'TDEDUCT',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
tax_base_amount_account = self.env['account.account'].create({
'name': 'TAX_BASE',
'code': 'TBASE',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
self.env.company.account_cash_basis_base_account_id = tax_base_amount_account
self.env.company.tax_exigibility = True
@ -1138,20 +1140,17 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'code': 'TWAIT',
'account_type': 'liability_current',
'reconcile': True,
'company_id': self.company_data['company'].id,
})
tax_final_account = self.env['account.account'].create({
'name': 'TAX_TO_DEDUCT',
'code': 'TDEDUCT',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
default_expense_account = self.company_data['default_account_expense']
not_default_expense_account = self.env['account.account'].create({
'name': 'NOT_DEFAULT_EXPENSE',
'code': 'NDE',
'account_type': 'expense',
'company_id': self.company_data['company'].id,
})
self.env.company.tax_exigibility = True
tax_tags = defaultdict(dict)
@ -1218,7 +1217,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
# check caba move
partial_rec = invoice.mapped('line_ids.matched_credit_ids')
caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)])
# all amls with tax_tag should all have tax_tag_invert at True since the caba move comes from a vendor refund
expected_values = [
{
'tax_line_id': False,
@ -1228,7 +1226,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': not_default_expense_account.id,
'debit': 800.0,
'credit': 0.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1238,7 +1235,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': not_default_expense_account.id,
'debit': 0.0,
'credit': 800.0,
'tax_tag_invert': True,
},
{
'tax_line_id': False,
@ -1248,7 +1244,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': default_expense_account.id,
'debit': 0.0,
'credit': 300.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1258,7 +1253,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': default_expense_account.id,
'debit': 300.0,
'credit': 0.0,
'tax_tag_invert': True,
},
{
'tax_line_id': False,
@ -1268,7 +1262,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': tax_waiting_account.id,
'debit': 50.0,
'credit': 0.0,
'tax_tag_invert': False,
},
{
'tax_line_id': tax.id,
@ -1278,7 +1271,6 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon):
'account_id': tax_final_account.id,
'debit': 0.0,
'credit': 50.0,
'tax_tag_invert': True,
},
]
self.assertRecordValues(caba_move.line_ids, expected_values)

View file

@ -8,6 +8,12 @@ from odoo import Command
@tagged('post_install', '-at_install')
class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR', rounding=0.001)
def _dispatch_move_lines(self, moves):
base_lines = moves.line_ids\
.filtered(lambda x: x.tax_ids and not x.tax_line_id)\
@ -19,9 +25,9 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
def _get_tax_details(self, fallback=False, extra_domain=None):
domain = [('company_id', '=', self.env.company.id)] + (extra_domain or [])
tax_details_query, tax_details_params = self.env['account.move.line']._get_query_tax_details_from_domain(domain, fallback=fallback)
tax_details_query = self.env['account.move.line']._get_query_tax_details_from_domain(domain, fallback=fallback)
self.env['account.move.line'].flush_model()
self.cr.execute(tax_details_query, tax_details_params)
self.cr.execute(tax_details_query)
tax_details_res = self.cr.dictfetchall()
return sorted(tax_details_res, key=lambda x: (x['base_line_id'], abs(x['base_amount']), abs(x['tax_amount'])))
@ -293,7 +299,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
},
{
'base_line_id': base_lines[0].id,
'tax_line_id': tax_lines[0].id,
'tax_line_id': tax_lines[1].id,
'base_amount': -1000.0,
'tax_amount': -100.0,
},
@ -323,7 +329,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
},
{
'base_line_id': base_lines[1].id,
'tax_line_id': tax_lines[1].id,
'tax_line_id': tax_lines[0].id,
'base_amount': -1000.0,
'tax_amount': -100.0,
},
@ -901,7 +907,8 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
Command.create({
'name': 'line3',
'account_id': self.company_data['default_account_revenue'].id,
'price_unit': -400.0,
'price_unit': 400.0,
'quantity': -1,
'tax_ids': [Command.set(fixed_tax.ids)],
}),
]
@ -1037,7 +1044,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
'credit': 0.0,
'amount_currency': 2400.0,
'account_id': self.company_data['default_account_revenue'].id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'tax_ids': [Command.set(percent_tax.ids)],
}),
Command.create({
@ -1046,7 +1053,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
'credit': 0.0,
'amount_currency': 6000.0,
'account_id': self.company_data['default_account_revenue'].id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'tax_ids': [Command.set(percent_tax.ids)],
}),
# Tax lines
@ -1056,7 +1063,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
'credit': 0.0,
'amount_currency': 360.0,
'account_id': self.company_data['default_account_revenue'].id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'tax_repartition_line_id': tax_rep.id,
}),
Command.create({
@ -1065,7 +1072,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
'credit': 0.0,
'amount_currency': 200.0,
'account_id': self.company_data['default_account_revenue'].id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'tax_repartition_line_id': tax_rep.id,
}),
# Balance
@ -1212,6 +1219,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
self.assertTotalAmounts(invoice, tax_details)
def test_tax_on_payment(self):
self.company_data['default_account_assets'].reconcile = True
percent_tax = self.env['account.tax'].create({
'name': "percent_tax",
'amount_type': 'percent',
@ -1252,7 +1260,7 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
def test_amounts_sign(self):
for tax_sign in (1, -1):
tax = self.env['account.tax'].create({
'name': "tax",
'name': f"tax {tax_sign}",
'amount_type': 'percent',
'amount': tax_sign * 10.0,
})
@ -1292,3 +1300,98 @@ class TestAccountTaxDetailsReport(AccountTestInvoicingCommon):
for amount in amounts],
)
self.assertTotalAmounts(invoice, tax_details)
def test_multiple_same_tax_lines_with_analytic(self):
""" One Invoice line with analytic_distribution and another without analytic_distribution with same group of tax"""
analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan with Tax details'})
analytic_account = self.env['account.analytic.account'].create({
'name': 'Analytic account with Tax details',
'plan_id': analytic_plan.id,
'company_id': False,
})
# Don't set analytic to False here. allowed ORM to do it becosue it's set SQL Null
child1_tax = self.env['account.tax'].create({
'name': "child1_tax",
'amount_type': 'percent',
'amount': 10.0,
'invoice_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': self.company_data['default_account_tax_sale'].id,
}),
],
'refund_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': self.company_data['default_account_tax_sale'].id,
}),
],
})
child2_tax = child1_tax.copy({'name': 'child2_tax', 'amount': 5.0})
tax_group = self.env['account.tax'].create({
'name': "tax_group",
'amount_type': 'group',
'children_tax_ids': [Command.set((child1_tax + child2_tax).ids)],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-01',
'invoice_line_ids': [
Command.create({
'name': 'line1',
'account_id': self.company_data['default_account_revenue'].id,
'price_unit': 1000.0,
'tax_ids': [Command.set(tax_group.ids)],
'analytic_distribution': {
analytic_account.id: 100,
},
}),
Command.create({
'name': 'line2',
'account_id': self.company_data['default_account_revenue'].id,
'price_unit': 100.0,
'tax_ids': [Command.set(tax_group.ids)],
}),
]
})
base_lines, tax_lines = self._dispatch_move_lines(invoice)
tax_details = self._get_tax_details()
self.assertTaxDetailsValues(
tax_details,
[
{
'base_line_id': base_lines[0].id,
'tax_line_id': tax_lines[1].id,
'base_amount': -1000.0,
'tax_amount': -50.0,
},
{
'base_line_id': base_lines[0].id,
'tax_line_id': tax_lines[0].id,
'base_amount': -1000.0,
'tax_amount': -100.0,
},
{
'base_line_id': base_lines[1].id,
'tax_line_id': tax_lines[1].id,
'base_amount': -100.0,
'tax_amount': -5.0,
},
{
'base_line_id': base_lines[1].id,
'tax_line_id': tax_lines[0].id,
'base_amount': -100.0,
'tax_amount': -10.0,
},
],
)
self.assertTotalAmounts(invoice, tax_details)

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# pylint: disable=bad-whitespace
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests.common import Form
from odoo.tests import tagged
from odoo.tests import Form, tagged
from odoo import fields, Command
from collections import defaultdict
@ -11,13 +10,15 @@ from collections import defaultdict
class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('HRK')
cls.invoice = cls.init_invoice('out_refund', products=cls.product_a+cls.product_b)
cls.product_line_vals_1 = {
'name': cls.product_a.name,
'name': 'product_a',
'product_id': cls.product_a.id,
'account_id': cls.product_a.property_account_income_id.id,
'partner_id': cls.partner_a.id,
@ -36,7 +37,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'date_maturity': False,
}
cls.product_line_vals_2 = {
'name': cls.product_b.name,
'name': 'product_b',
'product_id': cls.product_b.id,
'account_id': cls.product_b.property_account_income_id.id,
'partner_id': cls.partner_a.id,
@ -93,7 +94,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'date_maturity': False,
}
cls.term_line_vals_1 = {
'name': '',
'name': False,
'product_id': False,
'account_id': cls.company_data['default_account_receivable'].id,
'partner_id': cls.partner_a.id,
@ -117,7 +118,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'journal_id': cls.company_data['default_journal_sale'].id,
'date': fields.Date.from_string('2019-01-01'),
'fiscal_position_id': False,
'payment_reference': '',
'payment_reference': False,
'invoice_payment_term_id': cls.pay_terms_a.id,
'amount_untaxed': 1200.0,
'amount_tax': 210.0,
@ -143,7 +144,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'name': self.product_b.name,
'name': 'product_b',
'product_id': self.product_b.id,
'product_uom_id': self.product_b.uom_id.id,
'account_id': self.product_b.property_account_income_id.id,
@ -240,7 +241,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
def test_out_refund_line_onchange_partner_1(self):
move_form = Form(self.invoice)
move_form.partner_id = self.partner_b
move_form.payment_reference = 'turlututu'
move_form.save()
self.assertInvoiceValues(self.invoice, [
@ -262,25 +262,24 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'installment #1',
'partner_id': self.partner_b.id,
'account_id': self.partner_b.property_account_receivable_id.id,
'amount_currency': -423.0,
'credit': 423.0,
},
{
**self.term_line_vals_1,
'name': 'installment #2',
'partner_id': self.partner_b.id,
'account_id': self.partner_b.property_account_receivable_id.id,
'amount_currency': -987.0,
'credit': 987.0,
'date_maturity': fields.Date.from_string('2019-02-28'),
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'partner_id': self.partner_b.id,
'account_id': self.partner_b.property_account_receivable_id.id,
'amount_currency': -423.0,
'credit': 423.0,
},
], {
**self.move_vals,
'partner_id': self.partner_b.id,
'payment_reference': 'turlututu',
'fiscal_position_id': self.fiscal_pos_a.id,
'invoice_payment_term_id': self.pay_terms_b.id,
'amount_untaxed': 1200.0,
@ -319,25 +318,24 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'name': 'installment #1',
'account_id': self.partner_b.property_account_receivable_id.id,
'partner_id': self.partner_b.id,
'amount_currency': -414.0,
'credit': 414.0,
},
{
**self.term_line_vals_1,
'name': 'installment #2',
'account_id': self.partner_b.property_account_receivable_id.id,
'partner_id': self.partner_b.id,
'amount_currency': -966.0,
'credit': 966.0,
'date_maturity': fields.Date.from_string('2019-02-28'),
},
{
**self.term_line_vals_1,
'name': 'turlututu',
'account_id': self.partner_b.property_account_receivable_id.id,
'partner_id': self.partner_b.id,
'amount_currency': -414.0,
'credit': 414.0,
},
], {
**self.move_vals,
'partner_id': self.partner_b.id,
'payment_reference': 'turlututu',
'fiscal_position_id': self.fiscal_pos_a.id,
'invoice_payment_term_id': self.pay_terms_b.id,
'amount_untaxed': 1200.0,
@ -437,7 +435,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
def test_out_refund_line_onchange_cash_rounding_1(self):
# Required for `invoice_cash_rounding_id` to be visible in the view
self.env.user.groups_id += self.env.ref('account.group_cash_rounding')
self.env.user.group_ids += self.env.ref('account.group_cash_rounding')
# Test 'add_invoice_line' rounding
move_form = Form(self.invoice)
# Add a cash rounding having 'add_invoice_line'.
@ -592,44 +590,46 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
})
def test_out_refund_line_onchange_currency_1(self):
self.other_currency.rounding = 0.001
move_form = Form(self.invoice)
move_form.currency_id = self.currency_data['currency']
move_form.currency_id = self.other_currency
move_form.save()
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1000.0,
'debit': 500.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'debit': 100.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 180.0,
'debit': 90.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'debit': 15.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -1410.0,
'credit': 705.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
# Change the date to get another rate: 1/3 instead of 1/2.
@ -639,38 +639,38 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(self.invoice, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1000.0,
'debit': 333.33,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'debit': 66.67,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 180.0,
'debit': 60.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'debit': 10.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -1410.0,
'credit': 470.0,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'date': fields.Date.from_string('2016-01-01'),
})
@ -686,41 +686,41 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
{
**self.product_line_vals_1,
'quantity': 0.1,
'price_unit': 0.05,
'price_unit': 0.045,
'price_subtotal': 0.005,
'price_total': 0.006,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 0.005,
'debit': 0.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'debit': 66.67,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.001,
'debit': 10.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'debit': 10.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -260.006,
'credit': 86.67,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'date': fields.Date.from_string('2016-01-01'),
'amount_untaxed': 200.005,
'amount_tax': 60.001,
@ -736,11 +736,11 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
{
**self.product_line_vals_1,
'quantity': 0.1,
'price_unit': 0.05,
'price_subtotal': 0.01,
'price_total': 0.01,
'amount_currency': 0.01,
'debit': 0.01,
'price_unit': 0.045,
'price_subtotal': 0.0,
'price_total': 0.0,
'amount_currency': 0.0,
'debit': 0.0,
},
self.product_line_vals_2,
{
@ -751,17 +751,17 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.tax_line_vals_2,
{
**self.term_line_vals_1,
'amount_currency': -260.01,
'credit': 260.01,
'amount_currency': -260.0,
'credit': 260.0,
'date_maturity': fields.Date.from_string('2016-01-01'),
},
], {
**self.move_vals,
'currency_id': self.company_data['currency'].id,
'date': fields.Date.from_string('2016-01-01'),
'amount_untaxed': 200.01,
'amount_untaxed': 200.0,
'amount_tax': 60.0,
'amount_total': 260.01,
'amount_total': 260.0,
})
def test_out_refund_create_1(self):
@ -770,7 +770,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'out_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -791,37 +791,37 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1000.0,
'debit': 500.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'debit': 100.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 180.0,
'debit': 90.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'debit': 15.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -1410.0,
'credit': 705.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_out_refund_write_1(self):
@ -830,7 +830,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'out_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -855,37 +855,37 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1000.0,
'debit': 500.0,
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'debit': 100.0,
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 180.0,
'debit': 90.0,
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'debit': 15.0,
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -1410.0,
'credit': 705.0,
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_out_refund_create_storno(self):
@ -897,7 +897,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'move_type': 'out_refund',
'partner_id': self.partner_a.id,
'invoice_date': fields.Date.from_string('2019-01-01'),
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
Command.create({
@ -918,7 +918,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
self.assertInvoiceValues(move, [
{
**self.product_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 1000.0,
'balance': 500.0,
'debit': 0.0,
@ -926,7 +926,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.product_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 200.0,
'balance': 100.0,
'debit': 0.0,
@ -934,7 +934,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.tax_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 180.0,
'balance': 90.0,
'debit': 0.0,
@ -942,7 +942,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.tax_line_vals_2,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': 30.0,
'balance': 15.0,
'debit': 0.0,
@ -950,7 +950,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
{
**self.term_line_vals_1,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'amount_currency': -1410.0,
'balance': -705.0,
'debit': -705.0,
@ -958,7 +958,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
},
], {
**self.move_vals,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
})
def test_out_refund_reverse_caba(self):
@ -967,19 +967,16 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'code': 'TWAIT',
'account_type': 'liability_current',
'reconcile': True,
'company_id': self.company_data['company'].id,
})
tax_final_account = self.env['account.account'].create({
'name': 'TAX_TO_DEDUCT',
'code': 'TDEDUCT',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
tax_base_amount_account = self.env['account.account'].create({
'name': 'TAX_BASE',
'code': 'TBASE',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
self.env.company.account_cash_basis_base_account_id = tax_base_amount_account
self.env.company.tax_exigibility = True
@ -1093,20 +1090,17 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'code': 'TWAIT',
'account_type': 'liability_current',
'reconcile': True,
'company_id': self.company_data['company'].id,
})
tax_final_account = self.env['account.account'].create({
'name': 'TAX_TO_DEDUCT',
'code': 'TDEDUCT',
'account_type': 'asset_current',
'company_id': self.company_data['company'].id,
})
default_income_account = self.company_data['default_account_revenue']
not_default_income_account = self.env['account.account'].create({
'name': 'NOT_DEFAULT_INCOME',
'code': 'NDI',
'account_type': 'income',
'company_id': self.company_data['company'].id,
})
self.env.company.tax_exigibility = True
tax_tags = defaultdict(dict)
@ -1173,7 +1167,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
# check caba move
partial_rec = invoice.mapped('line_ids.matched_debit_ids')
caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)])
# all amls with tax_tag should all have tax_tag_invert at False since the caba move comes from an invoice refund
expected_values = [
{
'tax_line_id': False,
@ -1183,7 +1176,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': not_default_income_account.id,
'debit': 0.0,
'credit': 1000.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1193,7 +1185,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': not_default_income_account.id,
'debit': 1000.0,
'credit': 0.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1203,7 +1194,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': default_income_account.id,
'debit': 300.0,
'credit': 0.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1213,7 +1203,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': default_income_account.id,
'debit': 0.0,
'credit': 300.0,
'tax_tag_invert': False,
},
{
'tax_line_id': False,
@ -1223,7 +1212,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': tax_waiting_account.id,
'debit': 0.0,
'credit': 70.0,
'tax_tag_invert': False,
},
{
'tax_line_id': tax.id,
@ -1233,7 +1221,6 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon):
'account_id': tax_final_account.id,
'debit': 70.0,
'credit': 0.0,
'tax_tag_invert': False,
},
]
self.assertRecordValues(caba_move.line_ids, expected_values)

View file

@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMovePartnerCount(AccountTestInvoicingCommon):
def test_account_move_count(self):
self.env['account.move'].create([
{
'move_type': 'out_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})],
},
{
'move_type': 'in_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})],
},
]).action_post()
self.assertEqual(self.partner_a.supplier_rank, 1)
self.assertEqual(self.partner_a.customer_rank, 1)

View file

@ -8,22 +8,15 @@ from odoo import Command
class TestAccountMovePaymentsWidget(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.receivable_account = cls.company_data['default_account_receivable']
cls.payable_account = cls.company_data['default_account_payable']
cls.currency_data_2 = cls.setup_multi_currency_data(default_values={
'name': 'Stars',
'symbol': '',
'currency_unit_label': 'Stars',
'currency_subunit_label': 'Little Stars',
}, rate2016=6.0, rate2017=4.0)
cls.curr_1 = cls.company_data['currency']
cls.curr_2 = cls.currency_data['currency']
cls.curr_3 = cls.currency_data_2['currency']
cls.curr_2 = cls.setup_other_currency('EUR')
cls.curr_3 = cls.setup_other_currency('CAD', rates=[('2016-01-01', 6.0), ('2017-01-01', 4.0)])
cls.payment_2016_curr_1 = cls.env['account.move'].create({
'date': '2016-01-01',
@ -157,7 +150,7 @@ class TestAccountMovePaymentsWidget(AccountTestInvoicingCommon):
'date': '2016-01-01',
'invoice_date': '2016-01-01',
'partner_id': self.partner_a.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.curr_2.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 300,
@ -189,7 +182,7 @@ class TestAccountMovePaymentsWidget(AccountTestInvoicingCommon):
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': self.partner_a.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.curr_2.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 300,

View file

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveRounding(AccountTestInvoicingCommon):
def test_move_line_rounding(self):
"""Whatever arguments we give to the creation of an account move,
in every case the amounts should be properly rounded to the currency's precision.
In other words, we don't fall victim of the limitation introduced by 9d87d15db6dd40
Here the rounding should be done according to company_currency_id, which is a related
on move_id.company_id.currency_id.
In principle, it should not be necessary to add it to the create values,
since it is supposed to be computed by the ORM...
"""
move = self.env['account.move'].create({
'line_ids': [
(0, 0, {'debit': 100.0 / 3, 'account_id': self.company_data['default_account_revenue'].id}),
(0, 0, {'credit': 100.0 / 3, 'account_id': self.company_data['default_account_revenue'].id}),
],
})
self.assertEqual(
[(33.33, 0.0), (0.0, 33.33)],
move.line_ids.mapped(lambda x: (x.debit, x.credit)),
"Quantities should have been rounded according to the currency."
)

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
from odoo.tests.common import tagged
@tagged('post_install', '-at_install')
class TestTourRenderInvoiceReport(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env.user.write({
'groups_id': [
(6, 0, (cls.env.ref('account.group_account_manager') + cls.env.ref('base.group_system')).ids),
],
})
cls.out_invoice = cls.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_date': '2019-05-01',
'date': '2019-05-01',
'invoice_line_ids': [
(0, 0, {'name': 'line1', 'price_unit': 100.0}),
],
})
cls.out_invoice.action_post()
report_layout = cls.env.ref('web.report_layout_standard')
cls.company_data['company'].write({
'primary_color': '#123456',
'secondary_color': '#789101',
'external_report_layout_id': report_layout.view_id.id,
})
cls.env.ref('account.account_invoices_without_payment').report_type = 'qweb-html'
def test_render_account_document_layout(self):
self.start_tour('/web', 'account_render_report', login=self.env.user.login, timeout=200)

View file

@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import tagged
from freezegun import freeze_time
@tagged('post_install', '-at_install')
class TestAccountPartner(AccountTestInvoicingCommon):
@freeze_time("2023-05-31")
def test_days_sales_outstanding(self):
partner = self.env['res.partner'].create({'name': 'MyCustomer'})
self.assertEqual(partner.days_sales_outstanding, 0.0)
move_1 = self.init_invoice("out_invoice", partner, invoice_date="2023-01-01", amounts=[3000], taxes=self.tax_sale_a)
self.assertEqual(partner.days_sales_outstanding, 0.0)
move_1.action_post()
self.env.invalidate_all() #needed to force the update of partner.credit
self.assertEqual(partner.days_sales_outstanding, 150) #DSO = number of days since move_1
self.env['account.payment.register'].with_context(active_model='account.move', active_ids=move_1.ids).create({
'amount': move_1.amount_total,
'partner_id': partner.id,
'payment_type': 'inbound',
'partner_type': 'customer',
})._create_payments()
self.env.invalidate_all()
self.assertEqual(partner.days_sales_outstanding, 0.0)
self.init_invoice("out_invoice", partner, "2023-05-15", amounts=[1500], taxes=self.tax_sale_a, post=True)
self.env.invalidate_all()
self.assertEqual(partner.days_sales_outstanding, 50)
def test_account_move_count(self):
self.env['account.move'].create([
{
'move_type': 'out_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})],
},
{
'move_type': 'in_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})],
},
]).action_post()
# rank updates are updated in the post-commit phase
with self.enter_registry_test_mode():
self.env.cr.postcommit.run()
self.assertEqual(self.partner_a.supplier_rank, 1)
self.assertEqual(self.partner_a.customer_rank, 1)
# a second move is updated in postcommit
self.env['account.move'].create([
{
'move_type': 'out_invoice',
'date': '2017-01-02',
'invoice_date': '2017-01-02',
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {'name': 'aaaa', 'price_unit': 100.0})],
},
]).action_post()
# rank updates are updated in the post-commit phase
with self.enter_registry_test_mode():
self.env.cr.postcommit.run()
self.assertEqual(self.partner_a.customer_rank, 2)
def test_manually_write_partner_id(self):
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2025-04-29',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({
'quantity': 1,
'price_unit': 500.0,
'tax_ids': [Command.link(self.tax_sale_a.id)],
})],
})
move.action_post()
reversal = move._reverse_moves(cancel=True)
receivable_lines = (move + reversal).line_ids.filtered(lambda l: l.display_type == 'payment_term')
# Changing the partner should be possible despite being in locked periods as long as the VAT is the same
move.company_id.fiscalyear_lock_date = '9999-12-31'
move.company_id.tax_lock_date = '9999-12-31'
# Initially, move's commercial partner should be partner_a
self.assertEqual(move.commercial_partner_id, self.partner_a)
self.assertEqual(receivable_lines.mapped('reconciled'), [True, True])
self.partner_a.parent_id = self.partner_b
# Assert accounting move and move lines now use new commercial partner
self.assertEqual(move.commercial_partner_id, self.partner_b)
self.assertTrue(
all(line.partner_id == self.partner_b for line in move.line_ids),
"All move lines should be reassigned to the new commercial partner."
)
self.assertEqual(receivable_lines.mapped('reconciled'), [True, True])
def test_manually_write_partner_id_different_vat(self):
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2025-04-29',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({
'quantity': 1,
'price_unit': 500.0,
})],
})
move.action_post()
self.partner_a.vat = 'SOMETHING'
self.partner_b.vat = 'DIFFERENT'
with self.assertRaisesRegex(UserError, "different Tax ID"):
self.partner_a.parent_id = self.partner_b
def test_manually_write_partner_id_empty_string_vs_False(self):
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2025-04-29',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({
'quantity': 1,
'price_unit': 500.0,
})],
})
move.action_post()
self.partner_a.vat = ''
self.partner_b.vat = False
self.partner_a.parent_id = self.partner_b
def test_res_partner_bank(self):
self.env.user.group_ids = (
self.env.ref('base.group_partner_manager')
+ self.env.ref('account.group_account_user')
+ self.env.ref('account.group_validate_bank_account')
)
partner = self.env['res.partner'].create({'name': 'MyCustomer'})
account = self.env['res.partner.bank'].create({
'acc_number': '123456789',
'partner_id': partner.id,
})
account.allow_out_payment = True
with self.assertRaisesRegex(UserError, "has been trusted"), self.cr.savepoint():
account.write({'acc_number': '1234567890999'})
with self.assertRaisesRegex(UserError, "has been trusted"), self.cr.savepoint():
account.write({'sanitized_acc_number': '1234567890999'})
with self.assertRaisesRegex(UserError, "has been trusted"), self.cr.savepoint():
account.write({'partner_id': self.env['res.partner'].create({'name': 'MyCustomer 2'}).id})
account.allow_out_payment = False
account.write({'acc_number': '1234567890999000'})
self.env.user.group_ids -= self.env.ref('account.group_validate_bank_account')
with self.assertRaisesRegex(UserError, "You do not have the rights to trust"), self.cr.savepoint():
account.write({'allow_out_payment': True})

View file

@ -0,0 +1,168 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountPaymentDuplicateMoves(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.company_data['company']
cls.receivable = cls.company_data['default_account_receivable']
cls.payable = cls.company_data['default_account_payable']
cls.bank_journal = cls.company_data['default_journal_bank']
cls.comp_curr = cls.company_data['currency']
cls.payment_in = cls.env['account.payment'].create({
'amount': 50.0,
'payment_type': 'inbound',
'partner_id': cls.partner_a.id,
'destination_account_id': cls.receivable.id,
})
cls.payment_out = cls.env['account.payment'].create({
'amount': 50.0,
'payment_type': 'outbound',
'partner_id': cls.partner_a.id,
'destination_account_id': cls.payable.id,
})
cls.out_invoice_1 = cls.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 50.0, 'tax_ids': []})],
})
cls.in_invoice_1 = cls.env['account.move'].create({
'move_type': 'in_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 50.0, 'tax_ids': []})],
})
cls.out_invoice_2 = cls.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [(0, 0, {'product_id': cls.product_a.id, 'price_unit': 20.0, 'tax_ids': []})],
})
(cls.out_invoice_1 + cls.out_invoice_2 + cls.in_invoice_1).action_post()
def test_duplicate_payments(self):
""" Ensure duplicated payments are computed correctly for both inbound and outbound payments.
For it to be a duplicate, the partner, the date and the amount must be the same.
"""
payment_in_1 = self.payment_in
payment_out_1 = self.payment_out
# Different type but same partner, amount and date, no duplicate
self.assertRecordValues(payment_in_1, [{'duplicate_payment_ids': []}])
# Create duplicate payments
payment_in_2 = payment_in_1.copy(default={'date': payment_in_1.date})
payment_out_2 = payment_out_1.copy(default={'date': payment_out_1.date})
# Inbound payment finds duplicate inbound payment, not the outbound payment with same information
self.assertRecordValues(payment_in_2, [{
'duplicate_payment_ids': [payment_in_1.id],
}])
# Outbound payment finds duplicate outbound duplicate, not the inbound payment with same information
self.assertRecordValues(payment_out_2, [{
'duplicate_payment_ids': [payment_out_1.id],
}])
# Different date but same amount and same partner, no duplicate
payment_out_3 = payment_out_1.copy(default={'date': '2023-12-31'})
self.assertRecordValues(payment_out_3, [{'duplicate_payment_ids': []}])
# Different amount but same partner and same date, no duplicate
payment_out_4 = self.env['account.payment'].create({
'amount': 60.0,
'payment_type': 'outbound',
'partner_id': self.partner_a.id,
'destination_account_id': self.payable.id,
})
self.assertRecordValues(payment_out_4, [{'duplicate_payment_ids': []}])
# Different partner but same amount and same date, no duplicate
payment_out_5 = self.env['account.payment'].create({
'amount': 50.0,
'payment_type': 'outbound',
'partner_id': self.partner_b.id,
'destination_account_id': self.payable.id,
})
self.assertRecordValues(payment_out_5, [{'duplicate_payment_ids': []}])
def test_in_payment_multiple_duplicate_inbound_batch(self):
""" Ensure duplicated payments are computed correctly when updated in batch,
where payments are all of a single payment type
"""
payment_1 = self.payment_in
payment_2 = payment_1.copy(default={'date': payment_1.date})
payment_3 = payment_1.copy(default={'date': payment_1.date})
payments = payment_1 + payment_2 + payment_3
self.assertRecordValues(payments, [
{'duplicate_payment_ids': (payment_2 + payment_3).ids},
{'duplicate_payment_ids': (payment_1 + payment_3).ids},
{'duplicate_payment_ids': (payment_1 + payment_2).ids},
])
def test_in_payment_multiple_duplicate_multiple_journals(self):
""" Ensure duplicated payments are computed correctly when updated in batch,
with inbound and outbound payments with different journals
"""
payment_in_1 = self.payment_in
payment_out_1 = self.payment_out
# Create a different journals with a different outstanding account
bank_journal_B = self.bank_journal.copy()
bank_journal_B.inbound_payment_method_line_ids.payment_account_id = self.env['account.account'].create({
'name': 'Outstanding Payment Account B',
'code': 'OPAB',
'account_type': 'asset_current',
'reconcile': True,
})
# Create new payments in the second journal
payment_in_2 = payment_in_1.copy(default={'date': payment_in_1.date})
payment_in_2.journal_id = bank_journal_B
payment_out_2 = payment_out_1.copy(default={'date': payment_out_1.date})
payment_out_2.journal_id = bank_journal_B
payments = payment_in_1 + payment_out_1 + payment_in_2 + payment_out_2
self.assertRecordValues(payments, [
{'duplicate_payment_ids': [payment_in_2.id]},
{'duplicate_payment_ids': [payment_out_2.id]},
{'duplicate_payment_ids': [payment_in_1.id]},
{'duplicate_payment_ids': [payment_out_1.id]},
])
def test_register_payment_different_payment_types(self):
""" Test that payment wizard correctly calculates duplicate_payment_ids """
payment_1 = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=self.out_invoice_1.ids).create({'payment_date': self.payment_in.date})
payment_2 = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=self.in_invoice_1.ids).create({'payment_date': self.payment_out.date})
existing_payment_in = self.payment_in
existing_payment_out = self.payment_out
# Payment wizards flag unreconciled existing payments of the same payment type only
self.assertRecordValues(payment_1, [{'duplicate_payment_ids': [existing_payment_in.id]}])
self.assertRecordValues(payment_2, [{'duplicate_payment_ids': [existing_payment_out.id]}])
def test_register_payment_single_batch_duplicate_payments(self):
""" Test that duplicate_payment_ids is correctly calculated for single batches """
payment_1 = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=self.out_invoice_1.ids).create({'payment_date': self.payment_in.date})
payment_2 = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=self.out_invoice_2.ids).create({'payment_date': self.out_invoice_2.date})
active_ids = (self.out_invoice_1 + self.out_invoice_2).ids
combined_payments = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=active_ids).create({
'amount': 50.0, # amount can be changed manually
'group_payment': True,
'payment_difference_handling': 'open',
'payment_method_line_id': self.inbound_payment_method_line.id,
})
existing_payment = self.payment_in
self.assertRecordValues(payment_1, [{'duplicate_payment_ids': [existing_payment.id]}])
self.assertRecordValues(payment_2, [{'duplicate_payment_ids': []}]) # different amount, not a duplicate
# Combined payments does not show payment_1 as duplicate because payment_1 is reconciled
self.assertRecordValues(combined_payments, [{'duplicate_payment_ids': [existing_payment.id]}])

View file

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
from datetime import date, datetime, timedelta
from freezegun import freeze_time
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountPaymentItems(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.bill = cls.create_bill(due_date='2023-03-20')
cls.late_bill = cls.create_bill(due_date='2023-03-01')
cls.discount_bill = cls.create_bill(due_date='2023-03-20', discount_days=19)
cls.late_discount_bill = cls.create_bill(due_date='2023-04-20', discount_days=9)
@classmethod
def create_bill(cls, due_date, discount_days=None):
payment_term = cls.create_payment_term(due_date, discount_days)
bill = cls.env['account.move'].create({
'move_type': 'in_invoice',
'journal_id': cls.company_data['default_journal_purchase'].id,
'partner_id': cls.partner_a.id,
'date': '2023-03-15',
'invoice_date': '2023-03-01',
'invoice_date_due': due_date,
'invoice_payment_term_id': payment_term.id,
'invoice_line_ids': [(0, 0, {
'product_id': cls.product_a.id,
'quantity': 1,
'name': 'product_a',
'discount': 10.00,
'price_unit': 100,
'tax_ids': [],
'discount_date': date(2023, 3, 1) + timedelta(days=discount_days) if discount_days else False,
'date_maturity': due_date,
})]
})
bill.action_post()
return bill
@classmethod
def create_payment_term(cls, due_date, discount_days=None):
due_days = (datetime.strptime(due_date, '%Y-%m-%d').date() - date(2023, 3, 1)).days
payment_term = cls.env['account.payment.term'].create({
'name': 'Payment Term For Testing',
'early_discount': bool(discount_days),
'discount_days': discount_days if discount_days else False,
'discount_percentage': 5,
'line_ids': [
(0, 0, {
'value': 'percent',
'value_amount': 100,
'delay_type': 'days_after',
'nb_days': due_days,
}),
],
})
return payment_term
@freeze_time("2023-03-15")
def test_payment_date(self):
self.assertEqual(str(self.bill.line_ids[0].payment_date), '2023-03-20')
self.assertEqual(str(self.late_bill.line_ids[0].payment_date), '2023-03-01')
self.assertEqual(str(self.discount_bill.line_ids[0].payment_date), '2023-03-20')
self.assertEqual(str(self.late_discount_bill.line_ids[0].payment_date), '2023-04-20')
def test_search_payment_date(self):
for today, search, expected in [
('2023-03-05', '2023-03-01', self.late_bill),
('2023-03-05', '2023-03-30', self.bill + self.late_bill + self.discount_bill + self.late_discount_bill),
('2023-03-15', '2023-03-01', self.late_bill),
('2023-03-15', '2023-03-30', self.bill + self.late_bill + self.discount_bill),
('2023-03-25', '2023-03-01', self.late_bill),
('2023-03-25', '2023-03-30', self.bill + self.late_bill + self.discount_bill),
('2023-03-25', '2023-06-30', self.bill + self.late_bill + self.discount_bill + self.late_discount_bill),
]:
with freeze_time(today):
self.assertEqual(self.env['account.move.line'].search([
('payment_date', '=', search),
('partner_id', '=', self.partner_a.id),
]).move_id, expected)

View file

@ -0,0 +1,131 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged, Form
@tagged('post_install', '-at_install')
class TestAccountPaymentMethodLine(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.bank_journal_1 = cls.company_data['default_journal_bank']
cls.bank_journal_2 = cls.company_data['default_journal_bank'].copy()
cls.inbound_payment_method_line_1 = cls.env['account.payment.method.line'].create({
'name': 'new inbound payment method line 1',
'payment_method_id': cls.bank_journal_1.available_payment_method_ids[0].id,
'payment_type': 'inbound',
'journal_id': cls.bank_journal_1.id,
})
cls.inbound_payment_method_line_2 = cls.env['account.payment.method.line'].create({
'name': 'new inbound payment method line 2',
'payment_method_id': cls.bank_journal_1.available_payment_method_ids[0].id,
'payment_type': 'inbound',
'journal_id': cls.bank_journal_1.id,
})
cls.inbound_payment_method_line_other_journal = cls.env['account.payment.method.line'].create({
'name': 'new inbound payment method line other journal',
'payment_method_id': cls.bank_journal_2.available_payment_method_ids[0].id,
'payment_type': 'inbound',
'journal_id': cls.bank_journal_2.id,
})
cls.partner_c = cls.partner_a.copy()
cls.partner_a.property_inbound_payment_method_line_id = cls.inbound_payment_method_line_1
cls.partner_b.property_inbound_payment_method_line_id = cls.inbound_payment_method_line_2
cls.partner_c.property_inbound_payment_method_line_id = cls.inbound_payment_method_line_other_journal
cls.move_partner_a = cls.init_invoice(move_type='out_invoice', partner=cls.partner_a, products=cls.product_a, post=True)
cls.move_partner_b = cls.init_invoice(move_type='out_invoice', partner=cls.partner_b, products=cls.product_a, post=True)
cls.move_partner_c = cls.init_invoice(move_type='out_invoice', partner=cls.partner_c, products=cls.product_a, post=True)
def assertRegisterPayment(self, expected_journal, expected_payment_method, move_partner, payment_method_line=False):
if payment_method_line and expected_payment_method:
move_partner.preferred_payment_method_line_id = expected_payment_method
payment = self.env['account.payment.register'].with_context(
active_model='account.move',
active_ids=move_partner.ids,
).create({})
if not expected_payment_method:
expected_payment_method = payment.journal_id._get_available_payment_method_lines(payment.payment_type)[0]._origin
self.assertRecordValues(payment, [{
'journal_id': expected_journal.id,
'payment_method_line_id': expected_payment_method.id,
}])
def test_move_register_payment_wizard(self):
"""
This test will do a basic flow where we do a register payment from an invoice by using the register payment
wizard. If we have a payment method set on the partner, the preferred payment method will be the one from
the partner and so the wizard will have the payment method line from the partner. However, we can modify the
preferred payment line on the move and so the payment method line and journal of the wizard will be changed.
"""
# The preferred payment method will be the one set on the partner
self.assertRegisterPayment(
self.bank_journal_1,
self.inbound_payment_method_line_1,
self.move_partner_a,
)
# We then modify it from the move and check if that still works
self.assertRegisterPayment(
self.bank_journal_1,
self.inbound_payment_method_line_2,
self.move_partner_a,
True,
)
self.assertRegisterPayment(
self.bank_journal_2,
self.inbound_payment_method_line_other_journal,
self.move_partner_a,
True,
)
def test_multiple_moves_register_payment(self):
"""
This will test the register payment wizard when selecting multiple move with different partner to see if the
payment method lines are set correctly.
"""
# Test with two moves with same payment method lines and same partners
move_partner_a_copy = self.move_partner_a.copy()
move_partner_a_copy.action_post()
self.assertRegisterPayment(
self.bank_journal_1,
self.inbound_payment_method_line_1,
self.move_partner_a + move_partner_a_copy,
)
# Test with two moves with same payment method lines but different partners
self.partner_d = self.partner_a.copy()
move_partner_d = self.init_invoice(move_type='out_invoice', partner=self.partner_d, products=self.product_a, post=True)
self.assertRegisterPayment(
self.bank_journal_1,
self.inbound_payment_method_line_1,
self.move_partner_a + move_partner_d,
)
# Test with two moves with different partners and different payment method lines
self.assertRegisterPayment(
self.bank_journal_1,
None, # We will get in the assertRegisterPayment the first payment method line of the journal
self.move_partner_a + self.move_partner_b,
)
def test_move_register_payment_view(self):
"""
This test will check the payment method line on a payment from the account payment view.
When setting a partner the payment method must change and the journal if the payment method line is from
another journal that the one that has been set.
"""
with Form(self.env['account.payment'].with_context(default_partner_id=self.partner_a)) as pay_form:
self.assertEqual(pay_form.journal_id.id, self.bank_journal_1.id)
self.assertEqual(pay_form.payment_method_line_id.id, self.inbound_payment_method_line_1.id)
pay_form.partner_id = self.partner_b
self.assertEqual(pay_form.payment_method_line_id.id, self.inbound_payment_method_line_2.id)
pay_form.partner_id = self.partner_c
self.assertEqual(pay_form.journal_id.id, self.bank_journal_2.id)
self.assertEqual(pay_form.payment_method_line_id.id, self.inbound_payment_method_line_other_journal.id)

View file

@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import ValidationError
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountReport(AccountTestInvoicingCommon):
def test_copy_report(self):
""" Ensure that copying a report correctly adjust codes, formulas and subformulas. """
report = self.env['account.report'].create({
'name': "Report To Copy",
'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
'line_ids': [
Command.create({
'name': "test_line_1",
'code': "test_line_1",
'sequence': 1,
'expression_ids': [
Command.create({
'date_scope': 'strict_range',
'engine': 'external',
'formula': 'sum',
'label': 'balance',
}),
]
}),
Command.create({
'name': "test_line_2",
'code': "test_line_2",
'sequence': 2,
'expression_ids': [
Command.create({
'date_scope': 'strict_range',
'engine': 'aggregation',
'formula': 'test_line_1.balance',
'subformula': 'if_other_expr_above(test_line_1.balance, USD(0))',
'label': 'balance',
})
],
})
]
})
copy = report.copy()
# Ensure that the two line codes are updated.
self.assertEqual(copy.line_ids[0].code, 'test_line_1_COPY')
self.assertEqual(copy.line_ids[1].code, 'test_line_2_COPY')
# Ensure that the line 2 expression formula and subformula point to the correct code.
expression = copy.line_ids[1].expression_ids
self.assertEqual(expression.formula, 'test_line_1_COPY.balance')
self.assertEqual(expression.subformula, 'if_other_expr_above(test_line_1_COPY.balance, USD(0))')
with self.assertRaisesRegex(ValidationError, "Invalid formula for expression 'balance' of line 'test_line_2'"):
expression.write({
'engine': 'account_codes',
'formula': 'test(12)',
},
)

View file

@ -0,0 +1,134 @@
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountSectionAndSubsection(AccountTestInvoicingCommon):
def test_get_child_lines_with_one_taxes(self):
move = self.init_invoice('out_invoice')
move.invoice_line_ids = [
Command.create({
'name': "Section 1",
'display_type': 'line_section',
'collapse_prices': True,
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 100,
'tax_ids': self.tax_sale_a.ids,
}),
Command.create({
'name': "Subsection 1.1",
'display_type': 'line_subsection',
'collapse_composition': True,
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 200,
'tax_ids': self.tax_sale_a.ids,
}),
Command.create({
'product_id': self.product_b.id,
'price_unit': 100,
'tax_ids': self.tax_sale_a.ids,
}),
]
section_lines = move.invoice_line_ids[0]._get_child_lines()
expected_values = [
{'display_type': 'line_section', 'name': 'Section 1', 'price_subtotal': 400.0, 'taxes': ['15%']},
{'display_type': 'product', 'name': 'product_a', 'price_subtotal': 100.0, 'taxes': []},
{'display_type': 'product', 'name': 'Subsection 1.1', 'price_subtotal': 300.0, 'taxes': ['15%']},
]
for expected_value, line_value in zip(expected_values, section_lines):
for key, value in expected_value.items():
self.assertEqual(line_value[key], value)
def test_get_child_lines_with_multiple_taxes(self):
move = self.init_invoice('out_invoice')
move.invoice_line_ids = [
Command.create({
'name': "Section 1",
'display_type': 'line_section',
'collapse_prices': True,
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 100,
'tax_ids': self.tax_sale_a.ids,
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 100,
'tax_ids': self.tax_sale_b.ids,
}),
Command.create({
'name': "Subsection 1.1",
'display_type': 'line_subsection',
'collapse_composition': True,
}),
Command.create({
'product_id': self.product_b.id,
'price_unit': 200,
'tax_ids': self.tax_sale_a.ids,
}),
Command.create({
'product_id': self.product_b.id,
'price_unit': 200,
'tax_ids': self.tax_sale_b.ids,
}),
]
section_lines = move.invoice_line_ids[0]._get_child_lines()
expected_values = [
{'display_type': 'line_section', 'name': 'Section 1', 'price_subtotal': 600.0, 'taxes': ['15%', '15% (copy)']},
{'display_type': 'product', 'name': 'product_a', 'price_subtotal': 100.0, 'taxes': []},
{'display_type': 'product', 'name': 'product_a', 'price_subtotal': 100.0, 'taxes': []},
{'display_type': 'product', 'name': 'Subsection 1.1', 'price_subtotal': 200.0, 'taxes': ['15%']},
{'display_type': 'product', 'name': 'Subsection 1.1', 'price_subtotal': 200.0, 'taxes': ['15% (copy)']},
]
for expected_value, line_value in zip(expected_values, section_lines):
for key, value in expected_value.items():
self.assertEqual(line_value[key], value)
def test_get_child_lines_with_products_in_subsections(self):
move = self._create_invoice(
invoice_line_ids=[Command.create(vals) for vals in [
{
'name': "Section 1",
'display_type': 'line_section',
'collapse_prices': True,
},
{
'name': "Subsection 1.1",
'display_type': 'line_subsection',
'collapse_composition': False,
},
{
'product_id': self.product_a.id,
'price_unit': 200,
'tax_ids': self.tax_sale_a.ids,
},
{
'name': "Subsection 1.2",
'display_type': 'line_subsection',
'collapse_composition': False,
},
{
'product_id': self.product_b.id,
'price_unit': 200,
'tax_ids': self.tax_sale_b.ids,
},
]]
)
section_lines = move.invoice_line_ids[0]._get_child_lines()
expected_values = [
{'display_type': 'line_section', 'name': 'Section 1', 'price_subtotal': 400.0, 'taxes': ['15%', '15% (copy)']},
{'display_type': 'line_subsection', 'name': 'Subsection 1.1', 'price_subtotal': 200.0, 'taxes': ['15%']},
{'display_type': 'product', 'name': 'product_a', 'price_subtotal': 200.0, 'taxes': []},
{'display_type': 'line_subsection', 'name': 'Subsection 1.2', 'price_subtotal': 200.0, 'taxes': ['15% (copy)']},
{'display_type': 'product', 'name': 'product_b', 'price_subtotal': 200.0, 'taxes': []},
]
for expected_value, line_value in zip(expected_values, section_lines):
for key, value in expected_value.items():
self.assertEqual(line_value[key], value)

View file

@ -1,12 +1,57 @@
# -*- coding: utf-8 -*-
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
@tagged('post_install', '-at_install')
class TestAccountTax(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_data_2 = cls.setup_other_company()
@classmethod
def default_env_context(cls):
# OVERRIDE
return {}
def set_up_and_use_tax(self):
self.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2023-01-01',
'invoice_line_ids': [
Command.create({
'name': 'invoice_line',
'quantity': 1.0,
'price_unit': 100.0,
'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)],
}),
],
})
# Create two lines after creating the move so that those lines are not used in the move
self.company_data['default_tax_sale'].write({
'invoice_repartition_line_ids': [
Command.create({'repartition_type': 'tax', 'factor_percent': 0.0}),
],
'refund_repartition_line_ids': [
Command.create({'repartition_type': 'tax', 'factor_percent': 0.0}),
],
})
self.flush_tracking()
self.assertTrue(self.company_data['default_tax_sale'].is_used)
def flush_tracking(self):
""" Force the creation of tracking values. """
self.env.flush_all()
self.cr.flush()
def test_changing_tax_company(self):
''' Ensure you can't change the company of an account.tax if there are some journal entries '''
@ -26,5 +71,269 @@ class TestAccountTax(AccountTestInvoicingCommon):
],
})
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaises(UserError):
self.company_data['default_tax_sale'].company_id = self.company_data_2['company']
def test_logging_of_tax_update_when_tax_is_used(self):
""" Modifications of a used tax should be logged. """
self.set_up_and_use_tax()
self.company_data['default_tax_sale'].write({
'name': self.company_data['default_tax_sale'].name + ' MODIFIED',
'amount': 21,
'amount_type': 'fixed',
'type_tax_use': 'purchase',
'price_include_override': 'tax_included',
'include_base_amount': True,
'is_base_affected': False,
})
self.flush_tracking()
self.assertEqual(len(self.company_data['default_tax_sale'].message_ids), 1,
"Only 1 message should have been created when updating all the values.")
# There are 7 tracked values in account.tax and we update each of them, each on should be included in the message
self.assertEqual(len(self.company_data['default_tax_sale'].message_ids.tracking_value_ids), 7,
"The number of updated value should be 7.")
def test_logging_of_repartition_lines_addition_when_tax_is_used(self):
""" Adding repartition lines in a used tax should be logged. """
self.set_up_and_use_tax()
self.company_data['default_tax_sale'].write({
'invoice_repartition_line_ids': [
Command.create({'repartition_type': 'tax', 'factor_percent': -100.0}),
],
'refund_repartition_line_ids': [
Command.create({'repartition_type': 'tax', 'factor_percent': -100.0}),
],
})
self.flush_tracking()
previews = self.company_data['default_tax_sale'].message_ids.mapped('preview')
self.assertIn(
"New Invoice repartition line 4: -100.0 (Factor Percent) None (Account) None (Tax Grids) False (Use in tax closing)",
previews
)
self.assertIn(
"New Refund repartition line 4: -100.0 (Factor Percent) None (Account) None (Tax Grids) False (Use in tax closing)",
previews
)
def test_logging_of_repartition_lines_update_when_tax_is_used(self):
""" Updating repartition lines in a used tax should be logged. """
self.set_up_and_use_tax()
last_invoice_rep_line = self.company_data['default_tax_sale'].invoice_repartition_line_ids\
.filtered(lambda tax_rep: not tax_rep.factor_percent)
last_refund_rep_line = self.company_data['default_tax_sale'].refund_repartition_line_ids\
.filtered(lambda tax_rep: not tax_rep.factor_percent)
self.company_data['default_tax_sale'].write({
"invoice_repartition_line_ids": [
Command.update(last_invoice_rep_line.id, {
'factor_percent': -100,
'tag_ids': [Command.create({'name': 'TaxTag12345'})]
}),
],
"refund_repartition_line_ids": [
Command.update(last_refund_rep_line.id, {
'factor_percent': -100,
'account_id': self.company_data['default_account_tax_purchase'].id,
}),
],
})
self.flush_tracking()
previews = self.company_data['default_tax_sale'].message_ids.mapped('preview')
self.assertIn("Invoice repartition line 3: 0.0 -100.0 (Factor Percent) None ['TaxTag12345'] (Tax Grids)", previews)
self.assertIn("Refund repartition line 3: 0.0 -100.0 (Factor Percent) None 131000 Tax Paid (Account) False True (Use in tax closing)", previews)
def test_logging_of_repartition_lines_reordering_when_tax_is_used(self):
""" Reordering repartition lines in a used tax should be logged. """
self.set_up_and_use_tax()
last_invoice_rep_line = self.company_data['default_tax_sale'].invoice_repartition_line_ids\
.filtered(lambda tax_rep: not tax_rep.factor_percent)
last_refund_rep_line = self.company_data['default_tax_sale'].refund_repartition_line_ids\
.filtered(lambda tax_rep: not tax_rep.factor_percent)
self.company_data['default_tax_sale'].write({
"invoice_repartition_line_ids": [
Command.update(last_invoice_rep_line.id, {'sequence': 0}),
],
"refund_repartition_line_ids": [
Command.update(last_refund_rep_line.id, {'sequence': 0}),
],
})
self.flush_tracking()
previews = self.company_data['default_tax_sale'].message_ids.mapped('preview')
self.assertIn("Invoice repartition line 1: 100.0 0.0 (Factor Percent)", previews)
self.assertIn("Invoice repartition line 3: 0.0 100.0 (Factor Percent) None 251000 Tax Received (Account) False True (Use in tax closing)", previews)
def test_logging_of_repartition_lines_removal_when_tax_is_used(self):
""" Deleting repartition lines in a used tax should be logged. """
self.set_up_and_use_tax()
last_invoice_rep_line = self.company_data['default_tax_sale'].invoice_repartition_line_ids.sorted(key=lambda r: r.sequence)[-1]
last_refund_rep_line = self.company_data['default_tax_sale'].refund_repartition_line_ids.sorted(key=lambda r: r.sequence)[-1]
self.company_data['default_tax_sale'].write({
"invoice_repartition_line_ids": [
Command.delete(last_invoice_rep_line.id),
],
"refund_repartition_line_ids": [
Command.delete(last_refund_rep_line.id),
],
})
self.flush_tracking()
previews = self.company_data['default_tax_sale'].message_ids.mapped('preview')
self.assertIn(
"Removed Invoice repartition line 3: 0.0 (Factor Percent) None (Account) None (Tax Grids) False (Use in tax closing)",
previews
)
self.assertIn(
"Removed Refund repartition line 3: 0.0 (Factor Percent) None (Account) None (Tax Grids) False (Use in tax closing)",
previews
)
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_invoice = self.env['account.tax'].create({
'name': 'test_is_used_invoice',
'amount': '100',
})
self.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2023-01-01',
'invoice_line_ids': [
Command.create({
'name': 'invoice_line',
'quantity': 1.0,
'price_unit': 100.0,
'tax_ids': [Command.set(tax_invoice.ids)],
}),
],
})
tax_invoice.invalidate_model(fnames=['is_used'])
self.assertTrue(tax_invoice.is_used)
# Account.reconcile is another of transaction
tax_reconciliation = self.env['account.tax'].create({
'name': 'test_is_used_reconcilition',
'amount': '100',
})
self.env['account.reconcile.model'].create({
'name': "test_tax_is_used",
'line_ids': [Command.create({
'account_id': self.company_data['default_account_revenue'].id,
'tax_ids': [Command.set(tax_reconciliation.ids)],
})],
})
tax_reconciliation.invalidate_model(fnames=['is_used'])
self.assertTrue(tax_reconciliation.is_used)
def test_tax_no_duplicate_in_repartition_line(self):
""" Test that whenever a tax generate a second tax line
the same tax is not applied to the tax line.
"""
account_1 = self.company_data['default_account_tax_sale'].copy()
account_2 = self.company_data['default_account_tax_sale'].copy()
tax = self.env['account.tax'].create({
'name': "tax",
'amount': 15.0,
'include_base_amount': True,
'invoice_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': account_1.id,
}),
Command.create({
'factor_percent': -100,
'repartition_type': 'tax',
'account_id': account_2.id,
}),
],
'refund_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': 100,
'repartition_type': 'tax',
'account_id': account_1.id,
}),
Command.create({
'factor_percent': -100,
'repartition_type': 'tax',
'account_id': account_2.id,
}),
],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2019-01-01',
'invoice_line_ids': [
Command.create({
'name': 'invoice_line',
'quantity': 1.0,
'price_unit': 100.0,
'tax_ids': [Command.set(tax.ids)],
}),
],
})
self.assertRecordValues(invoice, [{
'amount_untaxed': 100.0,
'amount_tax': 0.0,
'amount_total': 100.0,
}])
self.assertRecordValues(invoice.line_ids, [
{'display_type': 'product', 'tax_ids': tax.ids, 'balance': -100.0, 'account_id': self.company_data['default_account_revenue'].id},
{'display_type': 'tax', 'tax_ids': [], 'balance': -15.0, 'account_id': account_1.id},
{'display_type': 'tax', 'tax_ids': [], 'balance': 15.0, 'account_id': account_2.id},
{'display_type': 'payment_term', 'tax_ids': [], 'balance': 100.0, 'account_id': self.company_data['default_account_receivable'].id},
])
def test_negative_factor_percent(self):
account_1 = self.company_data['default_account_tax_sale'].copy()
with self.assertRaisesRegex(ValidationError, r"Invoice and credit note distribution should have a total factor \(\+\) equals to 100\."):
self.env['account.tax'].create({
'name': "tax",
'amount': 15.0,
'include_base_amount': True,
'invoice_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': -100,
'repartition_type': 'tax',
'account_id': account_1.id,
}),
],
'refund_repartition_line_ids': [
Command.create({
'repartition_type': 'base',
}),
Command.create({
'factor_percent': -100,
'repartition_type': 'tax',
'account_id': account_1.id,
}),
],
})

View file

@ -0,0 +1,81 @@
from dateutil.relativedelta import relativedelta
from freezegun import freeze_time
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
@tagged('post_install', '-at_install')
class TestCheckAccountMoves(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.simple_accountman.group_ids = cls.env.ref('account.group_account_invoice')
cls.bank_journal = cls.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', cls.company.id)], limit=1)
def test_try_check_move_with_invoicing_user(self):
if 'accountant' not in self.env["ir.module.module"]._installed():
self.skipTest('accountant is not installed')
invoice = self._create_invoice(checked=True)
invoice.action_post()
with self.assertRaisesRegex(ValidationError, 'Validated entries can only be changed by your accountant.'):
invoice.with_user(self.simple_accountman).button_draft()
invoice.button_draft()
self.assertEqual(invoice.state, 'draft')
invoice.action_post()
invoice.checked = False
invoice.with_user(self.simple_accountman).button_draft()
self.assertEqual(invoice.state, 'draft')
def test_post_move_auto_check(self):
if 'accountant' not in self.env["ir.module.module"]._installed():
self.skipTest('accountant is not installed')
invoice_admin = self._create_invoice()
invoice_admin.action_post()
# As the user has admin right, the move should be auto checked
self.assertTrue(invoice_admin.checked)
invoice_invoicing = self._create_invoice(user_id=self.simple_accountman.id)
invoice_invoicing.with_user(self.simple_accountman).action_post()
# As the user has only invoicing right, the move shouldn't be checked
self.assertFalse(invoice_invoicing.checked)
def test_post_move_auto_check_with_auto_post(self):
if 'accountant' not in self.env["ir.module.module"]._installed():
self.skipTest('accountant is not installed')
invoice = self._create_invoice(auto_post='at_date', date=fields.Date.today())
self.assertFalse(invoice.checked)
with freeze_time(invoice.date + relativedelta(days=1)), self.enter_registry_test_mode():
self.env.ref('account.ir_cron_auto_post_draft_entry').method_direct_trigger()
self.assertTrue(invoice.checked)
def test_create_statement_line_auto_check(self):
"""Test if a user changes the reconciliation on a st_line, it marks the bank move as 'To Review'"""
if 'accountant' not in self.env["ir.module.module"]._installed():
self.skipTest('accountant is not installed')
payment = self.env['account.payment'].create({
'payment_type': 'inbound',
'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
'partner_type': 'customer',
'amount': 100,
'journal_id': self.company_data['default_journal_bank'].id,
'memo': 'INV/2025/00001',
})
payment.action_post()
bank_line_1 = self.env['account.bank.statement.line'].create([{
'journal_id': self.bank_journal.id,
'date': '2025-01-01',
'payment_ref': "INV/2025/00001",
'amount': -100,
}])
bank_line_1._try_auto_reconcile_statement_lines()
self.assertTrue(bank_line_1.move_id.checked)
with self.assertRaisesRegex(ValidationError, 'Validated entries can only be changed by your accountant.'):
bank_line_1.with_user(self.simple_accountman).delete_reconciled_line(payment.move_id.line_ids[0].id)

View file

@ -0,0 +1,271 @@
import logging
from odoo.addons.account.tests.common import AccountTestInvoicingCommon, AccountTestInvoicingHttpCommon
from odoo.exceptions import UserError
from odoo.fields import Command
from odoo.tests import tagged, new_test_user
_logger = logging.getLogger(__name__)
@tagged('post_install', '-at_install')
class TestAuditTrail(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env['base'].with_context(
tracking_disable=False,
mail_create_nolog=False,
mail_notrack=False,
).env
cls.env.company.restrictive_audit_trail = False
cls.move = cls.create_move()
@classmethod
def create_move(cls):
return cls.env['account.move'].create({
'date': '2021-04-01',
'line_ids': [
Command.create({
'balance': 100,
'account_id': cls.company_data['default_account_revenue'].id
}),
Command.create({
'balance': -100,
'account_id': cls.company_data['default_account_revenue'].id
}),
],
})
def get_trail(self, record):
self.env.cr.precommit.run()
return self.env['mail.message'].search([
('model', '=', record._name),
('res_id', '=', record.id),
])
def assertTrail(self, trail, expected):
self.assertEqual(len(trail), len(expected))
for message, expected_needle in zip(trail, expected[::-1]):
self.assertIn(expected_needle, message.account_audit_log_preview)
def test_can_unlink_draft(self):
self.env.company.restrictive_audit_trail = True
self.move.unlink()
def test_cant_unlink_posted(self):
self.env.company.restrictive_audit_trail = True
self.move.action_post()
self.move.button_draft()
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail"):
self.move.unlink()
def test_cant_unlink_message(self):
self.env.company.restrictive_audit_trail = True
self.move.action_post()
self.env.cr.flush()
audit_trail = self.get_trail(self.move)
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail"):
audit_trail.unlink()
def test_cant_unown_message(self):
self.env.company.restrictive_audit_trail = True
self.move.action_post()
self.env.cr.flush()
audit_trail = self.get_trail(self.move)
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail"):
audit_trail.res_id = 0
def test_cant_unlink_tracking_value(self):
self.env.company.restrictive_audit_trail = True
self.move.action_post()
self.env.cr.precommit.run()
self.move.name = 'track this!'
audit_trail = self.get_trail(self.move)
trackings = audit_trail.tracking_value_ids.sudo()
self.assertTrue(trackings)
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail"):
trackings.unlink()
def test_content(self):
messages = ["Journal Entry created"]
self.assertTrail(self.get_trail(self.move), messages)
self.move.action_post()
messages.append("Updated\nFalse ⇨ True (Reviewed)\nFalse ⇨ MISC/2021/04/0001 (Number)\nDraft ⇨ Posted (Status)")
self.assertTrail(self.get_trail(self.move), messages)
self.move.button_draft()
messages.append("Updated\nTrue ⇨ False (Reviewed)\nPosted ⇨ Draft (Status)")
self.assertTrail(self.get_trail(self.move), messages)
self.move.name = "nawak"
messages.append("Updated\nMISC/2021/04/0001 ⇨ nawak (Number)")
self.assertTrail(self.get_trail(self.move), messages)
self.move.line_ids = [
Command.update(self.move.line_ids[0].id, {'balance': 300}),
Command.update(self.move.line_ids[1].id, {'credit': 200}), # writing on debit/credit or balance both log
Command.create({
'balance': -100,
'account_id': self.company_data['default_account_revenue'].id,
})
]
messages.extend([
"updated\n100.0 ⇨ 300.0",
"updated\n-100.0 ⇨ -200.0",
"created\n ⇨ 400000 Product Sales (Account)\n0.0 ⇨ -100.0 (Balance)",
])
self.assertTrail(self.get_trail(self.move), messages)
self.move.line_ids[0].tax_ids = self.env.company.account_purchase_tax_id
suspense_account_code = self.env.company.account_journal_suspense_account_id.code
messages.extend([
"updated\n ⇨ 15% (Taxes)",
"created\n ⇨ 131000 Tax Paid (Account)\n0.0 ⇨ 45.0 (Balance)\nFalse ⇨ 15% (Label)",
f"created\n{suspense_account_code} Bank Suspense Account (Account)\n0.0 ⇨ -45.0 (Balance)\nFalse ⇨ Automatic Balancing Line (Label)",
])
self.assertTrail(self.get_trail(self.move), messages)
self.move.with_context(dynamic_unlink=True).line_ids.unlink()
messages.extend([
"deleted\n400000 Product Sales ⇨ (Account)\n300.0 ⇨ 0.0 (Balance)\n15% ⇨ (Taxes)",
"deleted\n400000 Product Sales ⇨ (Account)\n-200.0 ⇨ 0.0 (Balance)",
"deleted\n400000 Product Sales ⇨ (Account)\n-100.0 ⇨ 0.0 (Balance)",
"deleted\n131000 Tax Paid ⇨ (Account)\n45.0 ⇨ 0.0 (Balance)\n15% ⇨ False (Label)",
f"deleted\n{suspense_account_code} Bank Suspense Account ⇨ (Account)\n-45.0 ⇨ 0.0 (Balance)\nAutomatic Balancing Line ⇨ False (Label)",
])
self.assertTrail(self.get_trail(self.move), messages)
self.env.company.restrictive_audit_trail = True
messages_company = ["Updated\nFalse ⇨ True (Restrictive Audit Trail)"]
self.assertTrail(self.get_trail(self.company), messages_company)
def test_partner_notif(self):
"""Audit trail should not block partner notification."""
user = new_test_user(
self.env, 'test-user-notif', groups="base.group_portal",
notification_type='email',
)
# identify that user as being a customer
user.partner_id.sudo().customer_rank += 1
self.assertGreater(user.partner_id.customer_rank, 0)
user.partner_id.message_post(body='Test', partner_ids=user.partner_id.ids)
def test_partner_unlink(self):
"""Audit trail should not block partner unlink if they didn't create moves"""
partner = self.env['res.partner'].create({
'name': 'Test',
'customer_rank': 1,
})
partner.unlink()
@tagged('post_install', '-at_install')
class TestAuditTrailAttachment(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.company.restrictive_audit_trail = True
cls.document_installed = 'documents_account' in cls.env['ir.module.module']._installed()
if cls.document_installed:
folder_test = cls.env['documents.document'].create({
'name': 'folder_test',
'type': 'folder',
})
existing_setting = cls.env['documents.account.folder.setting'].sudo().search(
[('journal_id', '=', cls.company_data['default_journal_sale'].id)])
if existing_setting:
existing_setting.folder_id = folder_test
else:
cls.env['documents.account.folder.setting'].sudo().create({
'folder_id': folder_test.id,
'journal_id': cls.company_data['default_journal_sale'].id,
})
def _send_and_print(self, invoice):
return self.env['account.move.send'].with_context(
force_report_rendering=True,
)._generate_and_send_invoices(invoice)
def test_audit_trail_attachment(self):
invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({
'name': 'product',
'quantity': 1,
'price_unit': 100,
})],
}])
invoice.action_post()
self.assertFalse(invoice.message_main_attachment_id)
# Print the invoice for the first time
first_attachment = self._send_and_print(invoice)
self.assertTrue(first_attachment)
# Remove the attachment, it should only archive it instead of deleting it
first_attachment.unlink()
self.assertTrue(first_attachment.exists())
# But we cannot entirely remove it
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail."):
first_attachment.unlink()
# Print a second time the invoice, it generates a new attachment
invoice.invalidate_recordset()
second_attachment = self._send_and_print(invoice)
self.assertNotEqual(first_attachment, second_attachment)
# Make sure we can browse all the attachments in the UI (as it changes the main attachment)
first_attachment.register_as_main_attachment()
self.assertEqual(invoice.message_main_attachment_id, first_attachment)
second_attachment.register_as_main_attachment()
self.assertEqual(invoice.message_main_attachment_id, second_attachment)
if self.document_installed:
# Make sure we can change the version history of the document
document = self.env['documents.document'].search([
('res_model', '=', 'account.move'),
('res_id', '=', invoice.id),
('name', '=ilike', '%.pdf'),
])
self.assertTrue(document)
document.attachment_id = first_attachment
document.attachment_id = second_attachment
else:
_logger.runbot("Documents module is not installed, skipping part of the test")
def test_audit_trail_write_attachment(self):
invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({
'name': 'product',
'quantity': 1,
'price_unit': 100,
})],
}])
invoice.action_post()
self.assertFalse(invoice.message_main_attachment_id)
# Print the invoice for the first time
self._send_and_print(invoice)
attachment = invoice.message_main_attachment_id
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail."):
attachment.write({
'res_id': self.env.user.id,
'res_model': self.env.user._name,
})
with self.assertRaisesRegex(UserError, "remove parts of a restricted audit trail."):
attachment.datas = b'new data'
# Adding an attachment to the log notes should be allowed
another_attachment = self.env['ir.attachment'].create({
'name': 'doc.pdf',
'res_model': 'mail.compose.message',
# Ensures a bytes-like object with guessed mimetype = 'application/pdf' (checked in _except_audit_trail())
'datas': attachment.datas,
})
invoice.message_post(message_type='comment', attachment_ids=another_attachment.ids)

View file

@ -0,0 +1,314 @@
# -*- coding: utf-8 -*-
from contextlib import nullcontext, closing
from freezegun import freeze_time
from functools import partial
from odoo import Command, fields
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import UserError, ValidationError
from odoo.tests import tagged, Form
@tagged('post_install', '-at_install')
class TestCompanyBranch(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR')
cls.company_data['company'].write({
'child_ids': [
Command.create({'name': 'Branch A'}),
Command.create({'name': 'Branch B'}),
],
})
cls.cr.precommit.run() # load the CoA
cls.root_company = cls.company_data['company']
cls.branch_a, cls.branch_b = cls.root_company.child_ids
def test_chart_template_loading(self):
# Some company params have to be the same
self.assertEqual(self.root_company.currency_id, self.branch_a.currency_id)
self.assertEqual(self.root_company.fiscalyear_last_day, self.branch_a.fiscalyear_last_day)
self.assertEqual(self.root_company.fiscalyear_last_month, self.branch_a.fiscalyear_last_month)
# The accounts are shared
root_accounts = self.env['account.account'].search([('company_ids', 'parent_of', self.root_company.id)])
branch_a_accounts = self.env['account.account'].search([('company_ids', 'parent_of', self.branch_a.id)])
self.assertTrue(root_accounts)
self.assertEqual(root_accounts, branch_a_accounts)
# The journals are shared
root_journals = self.env['account.journal'].search([('company_id', 'parent_of', self.root_company.id)])
branch_a_journals = self.env['account.journal'].search([('company_id', 'parent_of', self.branch_a.id)])
self.assertTrue(root_journals)
self.assertEqual(root_journals, branch_a_journals)
def test_reconciliation(self):
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2016-01-01',
'company_id': self.branch_a.id,
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'product',
'price_unit': 1000,
})
],
})
invoice.action_post()
refund = self.env['account.move'].create({
'move_type': 'out_refund',
'invoice_date': '2017-01-01',
'company_id': self.root_company.id,
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'product',
'price_unit': 1000,
})
],
})
refund.action_post()
payment_lines = (invoice + refund).line_ids.filtered(lambda l: l.display_type == 'payment_term')
payment_lines.reconcile()
self.assertEqual(payment_lines.mapped('amount_residual'), [0, 0])
self.assertFalse(payment_lines.matched_debit_ids.exchange_move_id)
# Can still open the invoice with only it's branch accessible
self.env.invalidate_all()
with Form(invoice.with_context(allowed_company_ids=self.branch_a.ids)):
pass
def test_reconciliation_foreign_currency(self):
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2016-01-01',
'company_id': self.branch_a.id,
'currency_id': self.other_currency.id,
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'product',
'price_unit': 1000,
})
],
})
invoice.action_post()
refund = self.env['account.move'].create({
'move_type': 'out_refund',
'invoice_date': '2017-01-01',
'company_id': self.root_company.id,
'currency_id': self.other_currency.id,
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'product',
'price_unit': 1000,
})
],
})
refund.action_post()
payment_lines = (invoice + refund).line_ids.filtered(lambda l: l.display_type == 'payment_term')
payment_lines.reconcile()
self.assertEqual(payment_lines.mapped('amount_residual'), [0, 0])
self.assertTrue(payment_lines.matched_debit_ids.exchange_move_id)
self.assertTrue(payment_lines.matched_debit_ids.exchange_move_id.journal_id.company_id, invoice.company_id)
# Can still open the invoice with only it's branch accessible
self.env.invalidate_all()
with Form(invoice.with_context(allowed_company_ids=self.branch_a.ids)):
pass
def test_lock_dates(self):
moves = self.env['account.move'].search([])
moves.filtered(lambda x: x.state != 'draft').button_draft()
moves.unlink()
for lock, lock_sale, lock_purchase in [
('hard_lock_date', True, True),
('fiscalyear_lock_date', True, True),
('tax_lock_date', True, True),
('sale_lock_date', True, False),
('purchase_lock_date', False, True),
]:
for root_lock, branch_lock, invoice_date, company, move_type, failure_expected in (
# before both locks
('3021-01-01', '3022-01-01', '3020-01-01', self.root_company, 'in_invoice', lock_purchase),
('3021-01-01', '3022-01-01', '3020-01-01', self.root_company, 'out_invoice', lock_sale),
('3021-01-01', '3022-01-01', '3020-01-01', self.branch_a, 'in_invoice', lock_purchase),
('3021-01-01', '3022-01-01', '3020-01-01', self.branch_a, 'out_invoice', lock_sale),
# between root and branch lock
('3020-01-01', '3022-01-01', '3021-01-01', self.root_company, 'in_invoice', False),
('3020-01-01', '3022-01-01', '3021-01-01', self.root_company, 'out_invoice', False),
('3020-01-01', '3022-01-01', '3021-01-01', self.branch_a, 'in_invoice', lock_purchase),
('3020-01-01', '3022-01-01', '3021-01-01', self.branch_a, 'out_invoice', lock_sale),
# between branch and root lock
('3022-01-01', '3020-01-01', '3021-01-01', self.root_company, 'in_invoice', lock_purchase),
('3022-01-01', '3020-01-01', '3021-01-01', self.root_company, 'out_invoice', lock_sale),
('3022-01-01', '3020-01-01', '3021-01-01', self.branch_a, 'in_invoice', lock_purchase),
('3022-01-01', '3020-01-01', '3021-01-01', self.branch_a, 'out_invoice', lock_sale),
# after both locks
('3020-01-01', '3021-01-01', '3022-01-01', self.root_company, 'in_invoice', False),
('3020-01-01', '3021-01-01', '3022-01-01', self.root_company, 'out_invoice', False),
('3020-01-01', '3021-01-01', '3022-01-01', self.branch_a, 'in_invoice', False),
('3020-01-01', '3021-01-01', '3022-01-01', self.branch_a, 'out_invoice', False),
):
with self.subTest(
lock=lock,
root_lock=root_lock,
branch_lock=branch_lock,
invoice_date=invoice_date,
move_type=move_type,
company=company.name,
), closing(self.env.cr.savepoint()):
check = partial(self.assertRaises, UserError) if failure_expected else nullcontext
move = self.init_invoice(
move_type, amounts=[100], taxes=self.root_company.account_sale_tax_id,
invoice_date=invoice_date, post=True, company=company,
)
self.assertEqual(move.date, fields.Date.to_date(invoice_date))
with freeze_time('4000-01-01'): # ensure we don't lock in the future
self.root_company[lock] = root_lock
self.branch_a[lock] = branch_lock
with check():
move.button_draft()
def test_change_record_company(self):
account = self.env['account.account'].create({
'name': 'volatile',
'code': 'vola',
'account_type': 'income',
'company_ids': [Command.link(self.branch_a.id)]
})
account_lines = [Command.create({
'account_id': account.id,
'name': 'name',
})]
tax = self.env['account.tax'].create({
'name': 'volatile',
})
tax_lines = [Command.create({
'account_id': self.root_company.account_journal_suspense_account_id.id,
'tax_ids': [Command.set(tax.ids)],
'name': 'name',
})]
for record, lines, company_field in (
(account, account_lines, 'company_ids'),
(tax, tax_lines, 'company_id'),
):
with self.subTest(model=record._name):
self.env['account.move'].create({'company_id': self.branch_a.id, 'line_ids': lines})
# Can switch to main
record[company_field] = self.root_company
# Can switch back
record[company_field] = self.branch_a
# Can't use in main if owned by a branch
with self.assertRaisesRegex(UserError, 'belongs to another company'):
self.env['account.move'].create({'company_id': self.root_company.id, 'line_ids': lines})
# Can still switch to main
record[company_field] = self.root_company
# Can use in main now
self.env['account.move'].create({'company_id': self.root_company.id, 'line_ids': lines})
# Can't switch back to branch if used in main
with self.assertRaisesRegex(UserError, 'journal items linked'):
record[company_field] = self.branch_a
def test_branch_should_keep_parent_company_currency(self):
test_country = self.env['res.country'].create({
'name': 'Gold Country',
'code': 'zz',
'currency_id': self.other_currency.id
})
root_company = self.env['res.company'].create({
'name': 'Gold Company',
'country_id': test_country.id,
})
# with the generic_coa, try_loading forces currency_id to USD and account_fiscal_country_id to United States
self.env['account.chart.template'].try_loading('generic_coa', company=root_company, install_demo=False)
# So we write these values after try_loading
root_company.write({
'currency_id': test_country.currency_id.id,
'account_fiscal_country_id': test_country.id,
})
root_company.write({
'child_ids': [
Command.create({
'name': 'Gold Branch',
'country_id': test_country.id,
}),
],
})
self.env['account.chart.template'].try_loading('generic_coa', company=root_company.child_ids[0], install_demo=False)
self.assertEqual(root_company.currency_id, root_company.child_ids[0].currency_id)
def test_switch_company_currency(self):
"""
A user should not be able to switch the currency of another company
when that company already has posted account move lines.
"""
# Create company A (user's company)
company_a = self.env['res.company'].create({
'name': "Company A",
})
# Get company B from test setup
company_b = self.company_data['company']
# Create a purchase journal for company B
journal = self.env['account.journal'].create({
'name': "Vendor Bills Journal",
'code': "VEND",
'type': "purchase",
'company_id': company_b.id,
'currency_id': company_b.currency_id.id,
})
# Create an invoice for company B
invoice = self.env['account.move'].create({
'move_type': "in_invoice",
'company_id': company_b.id,
'journal_id': journal.id,
})
invoice.currency_id = self.env.ref('base.USD').id
# Add a line to the invoice using an expense account
self.env['account.move.line'].create({
'move_id': invoice.id,
'account_id': self.company_data["default_account_expense"].id,
'name': "Test Invoice Line",
'company_id': company_b.id,
})
# Create a user that only belongs to company A
user = self.env['res.users'].create({
'name': "User A",
'login': "user_a@example.com",
'email': "user_a@example.com",
'company_id': company_a.id,
'company_ids': [Command.set([company_a.id])],
})
# Try to change company B's currency as user A (should raise UserError)
user_env = self.env(user=user)
with self.assertRaises(UserError):
user_env['res.company'].browse(company_b.id).write({
'currency_id': self.env.ref('base.EUR').id,
})
def test_set_fiscalyear_last_day_to_negative_value(self):
"""Test that ensure that fiscalyear_last_day raises ValidationError when set
to negative value."""
with self.assertRaises(ValidationError):
self.root_company.fiscalyear_last_day = -1

View file

@ -0,0 +1,300 @@
from lxml import etree
from odoo.tests import TransactionCase, tagged
from odoo.addons.account.tools import dict_to_xml
@tagged('post_install', '-at_install')
class TestDictToXml(TransactionCase):
def assertXmlEqual(self, element1, element2):
self.assertEqual(etree.tostring(element1), etree.tostring(element2))
def test_10_empty_node(self):
element = dict_to_xml(node={}, tag='Node')
self.assertIsNone(element)
def test_11_render_empty_node(self):
element = dict_to_xml(node={}, tag='Node', render_empty_nodes=True)
self.assertXmlEqual(element, etree.fromstring('<Node/>'))
def test_21_simple_node(self):
node = {
'_tag': 'Node',
'_text': 'content',
'_comment': 'comment',
'attribute1': 'value1',
'attribute2': None,
}
element = dict_to_xml(node)
self.assertXmlEqual(element, etree.fromstring('<Node attribute1="value1">content</Node>'))
def test_22_simple_node_with_nsmap(self):
node = {
'_tag': 'ns1:Node',
'_text': 'content',
'_comment': 'comment',
'attribute1': 'value1',
'ns2:attribute2': 'value2',
'ns2:attribute3': None,
}
nsmap = {
None: 'urn:ns0',
'ns1': 'urn:ns1',
'ns2': 'urn:ns2',
}
element = dict_to_xml(node, nsmap=nsmap)
self.assertXmlEqual(element, etree.fromstring(
'<ns1:Node xmlns="urn:ns0" xmlns:ns1="urn:ns1" xmlns:ns2="urn:ns2" attribute1="value1" ns2:attribute2="value2">'
'content</ns1:Node>'
))
def test_31_compound_node(self):
node = {
'_tag': 'Parent',
'Child1': {
'_text': 'content 1',
'attribute1': 'value1',
},
'Child2': {
'_text': None,
'attribute2': None,
},
'Child3': [
{
'attribute3': 'value3',
},
{
'attribute4': 'value4',
},
],
}
element = dict_to_xml(node)
self.assertXmlEqual(element, etree.fromstring(
'<Parent><Child1 attribute1="value1">content 1</Child1>'
'<Child3 attribute3="value3"/><Child3 attribute4="value4"/>'
'</Parent>'
))
def test_32_compound_node_render_empty_nodes(self):
node = {
'_tag': 'Parent',
'Child1': {
'_text': 'content 1',
'attribute1': 'value1',
},
'Child2': {
'_text': None,
'attribute2': None,
},
'Child3': [
{
'attribute3': 'value3',
},
{
'attribute4': None,
},
None,
],
}
element = dict_to_xml(node, render_empty_nodes=True)
self.assertXmlEqual(element, etree.fromstring(
'<Parent><Child1 attribute1="value1">content 1</Child1>'
'<Child2/>'
'<Child3 attribute3="value3"/>'
'<Child3/>'
'</Parent>'
))
def test_33_compound_node_with_template(self):
node = {
'_tag': 'Parent',
'Child3': [
{
'attribute3': 'value3',
},
{
'attribute4': 'value4',
},
None,
],
'Child2': {
'_text': None,
'attribute2': None,
},
'Child1': {
'_text': 'content 1',
'attribute1': 'value1',
},
}
template = {
'Child1': {},
'Child3': {},
}
element = dict_to_xml(node, template=template)
self.assertXmlEqual(element, etree.fromstring(
'<Parent><Child1 attribute1="value1">content 1</Child1>'
'<Child3 attribute3="value3"/>'
'<Child3 attribute4="value4"/>'
'</Parent>'
))
def test_34_compound_node_with_template_raises(self):
node = {
'_tag': 'Parent',
'Child3': [
{
'attribute3': 'value3',
},
{
'attribute4': 'value4',
},
None,
],
'UnknownChild': {
'_text': 'something',
},
'Child1': {
'_text': 'content 1',
'attribute1': 'value1',
},
}
template = {
'Child1': {},
'Child3': {},
}
with self.assertRaises(ValueError):
dict_to_xml(node, template=template)
def test_35_compound_node_with_template_and_nsmap(self):
node = {
'_tag': 'Parent',
'ns3:Child3': [
{
'attribute3': 'value3',
},
{
'attribute4': 'value4',
},
None,
],
'ns2:Child2': {
'_text': None,
'attribute2': None,
},
'ns1:Child1': {
'_text': 'content 1',
'attribute1': 'value1',
},
}
template = {
'ns1:Child1': {},
'ns3:Child3': {},
}
nsmap = {
None: 'urn:ns0',
'ns1': 'urn:ns1',
'ns2': 'urn:ns2',
'ns3': 'urn:ns3',
}
element = dict_to_xml(node, template=template, nsmap=nsmap)
self.assertXmlEqual(element, etree.fromstring(
'<Parent xmlns="urn:ns0" xmlns:ns1="urn:ns1" xmlns:ns2="urn:ns2" xmlns:ns3="urn:ns3">'
'<ns1:Child1 attribute1="value1">content 1</ns1:Child1>'
'<ns3:Child3 attribute3="value3"/>'
'<ns3:Child3 attribute4="value4"/>'
'</Parent>'
))
def test_40_complex_example(self):
node = {
'_tag': 'Parent',
'_comment': 'comment',
'ns1:Child1': [
{
'ns2:attribute1': 'value1',
'ns3:SubChild1': {
'_text': 'content 1',
'attribute2': 'value2',
},
'ns3:SubChild2': {
'_text': None,
},
},
{
'ns2:attribute2': None,
'ns3:Subchild3': {
'_text': None
},
},
None,
],
}
template = {
'ns1:Child1': {
'ns3:SubChild1': {},
},
}
nsmap = {
None: 'urn:ns0',
'ns1': 'urn:ns1',
'ns2': 'urn:ns2',
'ns3': 'urn:ns3',
}
element = dict_to_xml(node, template=template, nsmap=nsmap)
self.assertXmlEqual(element, etree.fromstring(
'<Parent xmlns="urn:ns0" xmlns:ns1="urn:ns1" xmlns:ns2="urn:ns2" xmlns:ns3="urn:ns3">'
'<ns1:Child1 ns2:attribute1="value1">'
'<ns3:SubChild1 attribute2="value2">content 1</ns3:SubChild1>'
'</ns1:Child1>'
'</Parent>'
))
def test_41_complex_example_render_empty_nodes(self):
node = {
'_tag': 'Parent',
'_comment': 'comment',
'ns1:Child1': [
{
'ns2:attribute1': 'value1',
'ns3:SubChild1': {
'_text': 'content 1',
'attribute2': 'value2',
},
'ns3:SubChild2': {
'_text': None,
},
},
{
'ns2:attribute2': None,
'ns3:Subchild3': {
'_text': None
},
},
None,
],
}
template = {
'ns1:Child1': {
'ns3:SubChild1': {},
'ns3:SubChild2': {},
'ns3:Subchild3': {},
},
}
nsmap = {
None: 'urn:ns0',
'ns1': 'urn:ns1',
'ns2': 'urn:ns2',
'ns3': 'urn:ns3',
}
element = dict_to_xml(node, template=template, render_empty_nodes=True, nsmap=nsmap)
self.assertXmlEqual(element, etree.fromstring(
'<Parent xmlns="urn:ns0" xmlns:ns1="urn:ns1" xmlns:ns2="urn:ns2" xmlns:ns3="urn:ns3">'
'<ns1:Child1 ns2:attribute1="value1">'
'<ns3:SubChild1 attribute2="value2">content 1</ns3:SubChild1>'
'<ns3:SubChild2/>'
'</ns1:Child1>'
'<ns1:Child1>'
'<ns3:Subchild3/>'
'</ns1:Child1>'
'</Parent>'
))

View file

@ -0,0 +1,66 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.addons.digest.tests.common import TestDigestCommon
from odoo.tools import mute_logger
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountDigest(TestDigestCommon):
@classmethod
@mute_logger('odoo.models.unlink')
def setUpClass(cls):
super().setUpClass()
account1 = cls.env['account.account'].search([('internal_group', '=', 'income'), ('company_ids', '=', cls.company_1.id)], limit=1)
account2 = cls.env['account.account'].search([('internal_group', '=', 'expense'), ('company_ids', '=', cls.company_1.id)], limit=1)
cls.env['account.journal'].with_company(cls.company_2).create({
'name': 'Test Journal',
'code': 'code',
'type': 'general',
})
comp2_account, comp2_account2 = cls.env['account.account'].create([{
'name': 'Account 1 Company 2',
'account_type': 'expense_depreciation',
'code': 'aaaaaa',
'company_ids': [Command.link(cls.company_2.id)],
}, {
'name': 'Account 2 Company 2',
'account_type': 'income_other',
'code': 'bbbbbb',
'company_ids': [Command.link(cls.company_2.id)],
}])
cls.env['account.move'].search([]).state = 'draft'
moves = cls.env['account.move'].create({
'line_ids': [
(0, 0, {'debit': 5, 'credit': 0, 'account_id': account1.id}),
(0, 0, {'debit': 0, 'credit': 5, 'account_id': account2.id}),
(0, 0, {'debit': 8, 'credit': 0, 'account_id': account1.id}),
(0, 0, {'debit': 0, 'credit': 8, 'account_id': account2.id}),
],
})
moves |= cls.env['account.move'].with_company(cls.company_2).create({
'line_ids': [
(0, 0, {'debit': 0, 'credit': 2, 'account_id': comp2_account.id}),
(0, 0, {'debit': 2, 'credit': 0, 'account_id': comp2_account2.id}),
],
})
moves.state = 'posted'
def test_kpi_account_total_revenue_value(self):
self.assertEqual(int(self.digest_1.kpi_account_total_revenue_value), -13)
self.assertEqual(int(self.digest_2.kpi_account_total_revenue_value), -2)
self.assertEqual(int(self.digest_3.kpi_account_total_revenue_value), -13)
self.digest_3.invalidate_recordset()
self.assertEqual(
int(self.digest_3.with_company(self.company_2).kpi_account_total_revenue_value),
-2,
msg='When no company is set, the KPI must be computed based on the current company',
)

View file

@ -0,0 +1,139 @@
from io import BytesIO
from zipfile import ZipFile
from odoo.fields import Command
from odoo.tests.common import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
@tagged('post_install', '-at_install')
class TestDownloadDocs(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
invoice_1 = cls.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [Command.create({'price_unit': 100})],
'attachment_ids': [Command.create({'name': "Attachment", 'mimetype': 'text/plain', 'res_model': 'account.move', 'datas': "test"})],
})
invoice_2 = cls.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [Command.create({'price_unit': 200})]
})
cls.invoices = invoice_1 + invoice_2
cls.invoices.action_post()
cls.invoices._generate_and_send()
assert invoice_1.invoice_pdf_report_id and invoice_2.invoice_pdf_report_id
def test_download_invoice_attachments_not_auth(self):
url = f'/account/download_invoice_attachments/{",".join(map(str, self.invoices.invoice_pdf_report_id.ids))}'
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
self.assertIn(
'oe_login_form',
res.content.decode('utf-8'),
'When not authenticated, the download is not possible.'
)
def test_download_invoice_attachments_one(self):
attachment = self.invoices[0].invoice_pdf_report_id
url = f'/account/download_invoice_attachments/{attachment.id}'
self.authenticate(self.env.user.login, self.env.user.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, attachment.raw)
def test_download_invoice_attachments_multiple(self):
attachments = self.invoices.invoice_pdf_report_id
url = f'/account/download_invoice_attachments/{",".join(map(str, attachments.ids))}'
self.authenticate(self.env.user.login, self.env.user.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
self.assertEqual(
zip_file.namelist(),
self.invoices.invoice_pdf_report_id.mapped('name'),
)
def test_download_invoice_documents_filetype_one(self):
url = f'/account/download_invoice_documents/{self.invoices[0].id}/pdf'
self.authenticate(self.env.user.login, self.env.user.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, self.invoices[0].invoice_pdf_report_id.raw)
def test_download_invoice_documents_filetype_multiple(self):
url = f'/account/download_invoice_documents/{",".join(map(str, self.invoices.ids))}/pdf'
self.authenticate(self.env.user.login, self.env.user.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
self.assertEqual(
zip_file.namelist(),
self.invoices.invoice_pdf_report_id.mapped('name'),
)
def test_download_invoice_documents_filetype_all(self):
self.authenticate(self.env.user.login, self.env.user.login)
url = f'/account/download_invoice_documents/{",".join(map(str, self.invoices.ids))}/all'
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
file_names = zip_file.namelist()
self.assertEqual(len(file_names), 2)
self.assertTrue(self.invoices[0].invoice_pdf_report_id.name in file_names)
self.assertTrue(self.invoices[1].invoice_pdf_report_id.name in file_names)
def test_download_moves_attachments(self):
self.authenticate(self.env.user.login, self.env.user.login)
url = f'/account/download_move_attachments/{",".join(map(str, self.invoices.ids))}'
attachment_names = sorted([doc['filename'] for invoice in self.invoices for doc in invoice._get_invoice_legal_documents_all()])
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
file_names = sorted(zip_file.namelist())
self.assertEqual(file_names, attachment_names)
def test_download_moves_attachments_with_bills(self):
bill = self.init_invoice('in_invoice', products=self.product_a, post=True)
bill.message_main_attachment_id = self.env['ir.attachment'].create({'name': "Attachment", 'mimetype': 'text/plain', 'res_model': 'account.move', 'datas': "test_bill"})
attachment_names = [bill.message_main_attachment_id.name]
self.authenticate(self.env.user.login, self.env.user.login)
url = f'/account/download_move_attachments/{bill.id}'
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
file_names = sorted(zip_file.namelist())
self.assertEqual(file_names, attachment_names)
def test_download_moves_attachments_with_duplicate_names(self):
bill_1 = self.init_invoice('in_invoice', products=self.product_a, post=True)
bill_2 = self.init_invoice('in_invoice', products=self.product_a, post=True)
bill_3 = self.init_invoice('in_invoice', products=self.product_a, post=True)
att_name = "Attachment"
bill_1.message_main_attachment_id = self.env['ir.attachment'].create({'name': att_name, 'mimetype': 'text/plain', 'res_model': 'account.move', 'datas': "test_bill"})
bill_2.message_main_attachment_id = self.env['ir.attachment'].create({'name': att_name, 'mimetype': 'text/plain', 'res_model': 'account.move', 'datas': "test_bill"})
bill_3.message_main_attachment_id = self.env['ir.attachment'].create({'name': f"{att_name} (1)", 'mimetype': 'text/plain', 'res_model': 'account.move', 'datas': "test_bill"})
attachment_names = [att_name, f"{att_name} (1)", f"{att_name} (1) (1)"]
self.authenticate(self.env.user.login, self.env.user.login)
url = f'/account/download_move_attachments/{bill_1.id},{bill_2.id},{bill_3.id}'
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
with ZipFile(BytesIO(res.content)) as zip_file:
file_names = sorted(zip_file.namelist())
self.assertEqual(file_names, attachment_names)
att_name = "Attachment.ext"
bill_1.message_main_attachment_id.name = att_name
bill_2.message_main_attachment_id.name = att_name
attachment_names = [f"{att_name.split('.')[0]} (1).{att_name.split('.')[1]}", att_name]
url = f'/account/download_move_attachments/{bill_1.id},{bill_2.id}'
res = self.url_open(url)
with ZipFile(BytesIO(res.content)) as zip_file:
file_names = sorted(zip_file.namelist())
self.assertEqual(file_names, attachment_names)

View file

@ -1,7 +0,0 @@
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('external_l10n', '-at_install', 'post_install', '-standard', 'external')
class TestDownloadXsds(TransactionCase):
def test_download_xsds(self):
self.env['ir.attachment'].action_download_xsd_files()

View file

@ -0,0 +1,57 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import Form
from odoo.addons.base.tests.common import SavepointCaseWithUserDemo
class TestDuplicatePartnerBank(SavepointCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_a = cls.env['res.company'].create({"name": "companyA"})
cls.user_a = cls.env['res.users'].with_company(cls.company_a).create({"name": "userA", "login": "test@test.com", "group_ids": [(6, 0, [cls.env.ref("base.group_user").id, cls.env.ref("base.group_partner_manager").id])]})
cls.partner_a = cls.env['res.partner'].with_user(cls.user_a).create({"name": "PartnerA", "company_id": cls.company_a.id})
cls.partner_bank_a = cls.env['res.partner.bank'].with_user(cls.user_a).create({"acc_number": "12345", "partner_id": cls.partner_a.id})
cls.company_b = cls.env['res.company'].create({"name": "companyB"})
cls.user_b = cls.env['res.users'].with_company(cls.company_b).create({"name": "userB", "login": "test1@test.com", "group_ids": [(6, 0, [cls.env.ref("base.group_user").id, cls.env.ref("base.group_partner_manager").id])]})
cls.partner_b = cls.env['res.partner'].with_user(cls.user_b).create({"name": "PartnerB", "company_id": cls.company_b.id})
cls.partner_bank_b = cls.env['res.partner.bank'].with_user(cls.user_b).create({"acc_number": "12345", "partner_id": cls.partner_b.id})
def test_duplicate_acc_number_different_company(self):
self.assertFalse(self.partner_bank_b.duplicate_bank_partner_ids)
def test_duplicate_acc_number_no_company(self):
self.partner_a.company_id = False
self.partner_bank_a.company_id = False
self.partner_b.company_id = False
self.partner_bank_b.company_id = False
self.assertTrue(self.partner_bank_a.duplicate_bank_partner_ids, self.partner_a)
def test_duplicate_acc_number_b_company(self):
self.partner_a.company_id = False
self.partner_bank_a.company_id = False
self.assertTrue(self.partner_bank_b.duplicate_bank_partner_ids, self.partner_a)
def test_remove_bank_account_from_partner(self):
bank = self.env['res.bank'].create({'name': 'SBI Bank'})
partner = self.env['res.partner'].create({'name': 'Rich Cat'})
self.partner_bank_a.write({
'acc_number': '99999',
'bank_id': bank.id,
'partner_id': partner.id,
})
self.assertEqual(len(partner.bank_ids), 1)
with Form(partner) as partner_form:
partner_form.bank_ids.remove(0)
self.assertEqual(len(partner.bank_ids), 0)
def test_duplicate_acc_number_inactive_bank_account(self):
self.partner_bank_b.active = False
self.assertFalse(self.partner_bank_a.duplicate_bank_partner_ids)

View file

@ -0,0 +1,146 @@
<?xml version='1.0' encoding='utf-8'?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>F26/0045</cbc:ID>
<cbc:IssueDate>2026-02-28</cbc:IssueDate>
<cbc:DueDate>2026-02-01</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Test Invoice Note</cbc:Note>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:OrderReference>
<cbc:ID>F26/0045</cbc:ID>
</cac:OrderReference>
<cac:AdditionalDocumentReference>
<cbc:ID>test_pdf.pdf</cbc:ID>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename="test_pdf.pdf">
JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9Db3VudCAxCi9LaWRzIFszIDAgUl0KPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCAzMDAgMTQ0XQovQ29udGVudHMgNCAwIFIKL1Jlc291cmNlcyA8PAovRm9udCA8PAovRjEgNSAwIFIKPj4KPj4KPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0xlbmd0aCA0NAo+PgpzdHJlYW0KQlQgL0YxIDEyIFRmIDUwIDcwIFRkIChIZWxsbyBQREYpIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PAovVHlwZSAvRm9udAovU3VidHlwZSAvVHlwZTEKL05hbWUgL0YxCi9CYXNlRm9udCAvSGVsdmV0aWNhCj4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA2MSAwMDAwMCBuIAowMDAwMDAwMTE2IDAwMDAwIG4gCjAwMDAwMDAyMjAgMDAwMDAgbiAKMDAwMDAwMDMxNSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjM5MAolJUVPRgo=
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0208">0239843188</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Testing company</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>5000</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>BE0239843188</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Testing company</cbc:RegistrationName>
<cbc:CompanyID>BE0239843188</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Testing company</cbc:Name>
<cbc:Telephone>+32 123 12 12 12</cbc:Telephone>
<cbc:ElectronicMail>info@testingcompany.be</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0208">0239843189</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Odoo</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>5001</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>BE0239843189</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Odoo</cbc:RegistrationName>
<cbc:CompanyID>BE0239843189</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Odoo</cbc:Name>
<cbc:Telephone>+1 123-123-1234</cbc:Telephone>
<cbc:ElectronicMail>info@odoo.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cac:DeliveryLocation>
<cac:Address>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>5001</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode name="credit transfer">30</cbc:PaymentMeansCode>
<cbc:PaymentID>F26/0045</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>BE0239843189</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>REVOBEB2</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">713.14</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">3395.92</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">713.14</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">3395.92</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">3395.92</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">4109.06</cbc:TaxInclusiveAmount>
<cbc:PrepaidAmount currencyID="EUR">0.00</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="EUR">4109.06</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1.0</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">3395.92</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>PO1234</cbc:Description>
<cbc:Name>Commission</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">3395.92</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>

View file

@ -3,6 +3,7 @@
from odoo.tests import common
from odoo.exceptions import ValidationError
from odoo import Command
class TestFiscalPosition(common.TransactionCase):
@ -43,25 +44,23 @@ class TestFiscalPosition(common.TransactionCase):
country_id=fr.id))
cls.alberto = cls.res_partner.create(dict(
name="Alberto",
vat="BE0477472701",
vat="ZUÑ920208KL4",
country_id=mx.id))
cls.be_nat = cls.fp.create(dict(
name="BE-NAT",
auto_apply=True,
country_id=be.id,
vat_required=False,
sequence=10))
cls.fr_b2c = cls.fp.create(dict(
name="EU-VAT-FR-B2C",
auto_apply=True,
country_id=fr.id,
vat_required=False,
sequence=40))
cls.fr_b2b = cls.fp.create(dict(
name="EU-VAT-FR-B2B",
auto_apply=True,
country_id=fr.id,
vat_required=True,
sequence=40))
cls.fr_b2c = cls.fp.create(dict(
name="EU-VAT-FR-B2C",
auto_apply=True,
country_id=fr.id,
sequence=50))
def test_10_fp_country(self):
@ -71,90 +70,43 @@ class TestFiscalPosition(common.TransactionCase):
expected_pos.id,
message)
george, jc, ben, alberto = self.george, self.jc, self.ben, self.alberto
# B2B has precedence over B2C for same country even when sequence gives lower precedence
self.assertGreater(self.fr_b2b.sequence, self.fr_b2c.sequence)
assert_fp(george, self.fr_b2b, "FR-B2B should have precedence over FR-B2C")
self.fr_b2b.auto_apply = False
assert_fp(george, self.fr_b2c, "FR-B2C should match now")
self.fr_b2b.auto_apply = True
# Create positions matching on Country Group and on NO country at all
self.eu_intra_b2b = self.fp.create(dict(
name="EU-INTRA B2B",
auto_apply=True,
country_group_id=self.eu.id,
vat_required=True,
sequence=20))
self.world = self.fp.create(dict(
name="WORLD-EXTRA",
auto_apply=True,
vat_required=False,
sequence=30))
# Country match has higher precedence than group match or sequence
self.assertGreater(self.fr_b2b.sequence, self.eu_intra_b2b.sequence)
assert_fp(george, self.fr_b2b, "FR-B2B should have precedence over EU-INTRA B2B")
# B2B has precedence regardless of country or group match
self.assertGreater(self.eu_intra_b2b.sequence, self.be_nat.sequence)
assert_fp(jc, self.eu_intra_b2b, "EU-INTRA B2B should match before BE-NAT")
# Lower sequence = higher precedence if country/group and VAT matches
self.assertFalse(ben.vat) # No VAT set
assert_fp(ben, self.be_nat, "BE-NAT should match before EU-INTRA due to lower sequence")
# Remove BE from EU group, now BE-NAT should be the fallback match before the wildcard WORLD
self.be.write({'country_group_ids': [(3, self.eu.id)]})
self.assertTrue(jc.vat) # VAT set
assert_fp(jc, self.be_nat, "BE-NAT should match as fallback even w/o VAT match")
# No country = wildcard match only if nothing else matches
self.assertTrue(alberto.vat) # with VAT
assert_fp(alberto, self.world, "WORLD-EXTRA should match anything else (1)")
alberto.vat = False # or without
assert_fp(alberto, self.world, "WORLD-EXTRA should match anything else (2)")
self.assertFalse(self.ben.vat) # No VAT set
assert_fp(self.ben, self.be_nat, "BE-NAT should match before EU-INTRA due to lower sequence")
# Zip range
self.fr_b2b_zip100 = self.fr_b2b.copy(dict(zip_from=0, zip_to=5000, sequence=60))
george.zip = 6000
assert_fp(george, self.fr_b2b, "FR-B2B with wrong zip range should not match")
george.zip = 3000
assert_fp(george, self.fr_b2b_zip100, "FR-B2B with zip range should have precedence")
fr_b2b_zip100 = self.fr_b2b.copy(dict(zip_from=0, zip_to=5000, sequence=1))
self.george.zip = 6000
assert_fp(self.george, self.fr_b2b, "FR-B2B with wrong zip range should not match")
self.george.zip = 1234
assert_fp(self.george, fr_b2b_zip100, "FR-B2B with ok zip range should match")
self.george.zip = None
# States
self.fr_b2b_state = self.fr_b2b.copy(dict(state_ids=[(4, self.state_fr.id)], sequence=70))
george.state_id = self.state_fr
assert_fp(george, self.fr_b2b_zip100, "FR-B2B with zip should have precedence over states")
george.zip = False
assert_fp(george, self.fr_b2b_state, "FR-B2B with states should have precedence")
fr_b2b_state = self.fr_b2b.copy(dict(state_ids=[(4, self.state_fr.id)], sequence=1))
assert_fp(self.george, self.fr_b2b, "FR-B2B with states should not match")
self.george.state_id = self.state_fr
assert_fp(self.george, fr_b2b_state, "FR-B2B with states should match")
# Dedicated position has max precedence
george.property_account_position_id = self.be_nat
assert_fp(george, self.be_nat, "Forced position has max precedence")
self.george.property_account_position_id = self.be_nat
assert_fp(self.george, self.be_nat, "Forced position has max precedence")
def test_20_fp_one_tax_2m(self):
self.env.company.country_id = self.env.ref('base.us')
self.env['account.tax.group'].create(
{'name': 'Test Tax Group', 'company_id': self.env.company.id}
)
self.src_tax = self.env['account.tax'].create({'name': "SRC", 'amount': 0.0})
self.dst1_tax = self.env['account.tax'].create({'name': "DST1", 'amount': 0.0})
self.dst2_tax = self.env['account.tax'].create({'name': "DST2", 'amount': 0.0})
self.fp2m = self.fp.create({
'name': "FP-TAX2TAXES",
'tax_ids': [
(0,0,{
'tax_src_id': self.src_tax.id,
'tax_dest_id': self.dst1_tax.id
}),
(0,0,{
'tax_src_id': self.src_tax.id,
'tax_dest_id': self.dst2_tax.id
})
]
})
self.dst1_tax = self.env['account.tax'].create({'name': "DST1", 'amount': 0.0, 'fiscal_position_ids': [Command.set(self.fp2m.ids)], 'original_tax_ids': [Command.set(self.src_tax.ids)], 'sequence': 10})
self.dst2_tax = self.env['account.tax'].create({'name': "DST2", 'amount': 0.0, 'fiscal_position_ids': [Command.set(self.fp2m.ids)], 'original_tax_ids': [Command.set(self.src_tax.ids)], 'sequence': 5})
mapped_taxes = self.fp2m.map_tax(self.src_tax)
self.assertEqual(mapped_taxes, self.dst1_tax | self.dst2_tax)
@ -175,24 +127,22 @@ class TestFiscalPosition(common.TransactionCase):
'vat_required': True,
'sequence': 10,
})
fp_eu_priv = self.env['account.fiscal.position'].create({
'name': 'EU privé',
'auto_apply': True,
'country_group_id': self.eu.id,
'vat_required': False,
'sequence': 20,
})
fp_eu_intra = self.env['account.fiscal.position'].create({
'name': 'Régime Intra-Communautaire',
'auto_apply': True,
'country_group_id': self.eu.id,
'vat_required': True,
'sequence': 20,
})
fp_eu_priv = self.env['account.fiscal.position'].create({
'name': 'EU privé',
'auto_apply': True,
'country_group_id': self.eu.id,
'sequence': 30,
})
fp_eu_extra = self.env['account.fiscal.position'].create({
'name': 'Régime Extra-Communautaire',
'auto_apply': True,
'vat_required': False,
'sequence': 40,
})
@ -270,6 +220,77 @@ class TestFiscalPosition(common.TransactionCase):
fp_eu_extra
)
def test_domestic_fp_map_self(self):
self.env.company.country_id = self.us
self.env['account.tax.group'].create(
{'name': 'Test Tax Group', 'company_id': self.env.company.id}
)
fp = self.env['account.fiscal.position'].create({
'name': 'FP Self',
})
tax = self.env['account.tax'].create({
'name': 'Source Dest Tax',
'amount': 10,
'fiscal_position_ids': [Command.link(fp.id)],
})
self.assertEqual(fp.map_tax(tax), tax)
def test_domestic_fp(self):
"""
check if the domestic fiscal position is well computed in different scenarios.
"""
country_group, a_country_group = self.env['res.country.group'].create([{
'name': "One country group",
}, {
'name': "Alphabetically first country_group",
}])
my_country = self.env['res.country'].create({
'name': "Neverland",
'code': 'PP',
'country_group_ids': [Command.set(country_group.ids + a_country_group.ids)],
})
self.env.company.country_id = my_country
# AT case - no sequence, one country_id
fp_1, fp_2, fp_3 = self.env['account.fiscal.position'].create([{
'name': 'FP First',
'country_group_id': country_group.id,
}, {
'name': 'FP Second',
'country_id': my_country.id,
}, {
'name': 'FP 3',
'country_group_id': country_group.id,
}])
self.assertEqual(self.env.company.domestic_fiscal_position_id, fp_2)
# SA case - same sequence, one country_id
(fp_1 + fp_2 + fp_3).write({'sequence': 10})
fp_1.write({
'country_id': my_country.id,
'country_group_id': False,
})
fp_2.write({'country_id': False})
self.assertEqual(self.env.company.domestic_fiscal_position_id, fp_1)
# NL case - different sequence, both country_group_id and country_id on a fp
(fp_1 + fp_2).write({'country_group_id': country_group.id})
fp_1.write({'country_id': False})
fp_2.write({'country_id': my_country.id})
fp_3.write({'country_group_id': a_country_group.id})
self.assertEqual(self.env.company.domestic_fiscal_position_id, fp_2)
# Check that sequence is applied after the country
fp_2.write({'sequence': 20})
fp_3.write({'sequence': 15})
self.assertEqual(self.env.company.domestic_fiscal_position_id, fp_1)
# CH/LI case - one fp with country_group_id only, nothing for others
fp_1.write({'sequence': 30})
fp_2.write({'country_id': False})
fp_3.write({'country_group_id': False})
self.assertEqual(self.env.company.domestic_fiscal_position_id, fp_2)
def test_fiscal_position_constraint(self):
"""
Test fiscal position constraint by updating the record
@ -304,3 +325,21 @@ class TestFiscalPosition(common.TransactionCase):
'zip_from': '123',
'zip_to': '456',
}])
def test_fiscal_position_different_vat_country(self):
""" If the country is European, we need to be able to put the VAT of another country through the prefix"""
fiscal_position = self.fp.create({
'name': 'Special Delivery Case',
'country_id': self.env.ref('base.fr').id,
'foreign_vat': 'BE0477472701',
})
self.assertEqual(fiscal_position.foreign_vat, 'BE0477472701')
def test_get_first_fiscal_position(self):
fiscal_positions = self.fp.create([{
'name': f'fiscal_position_{sequence}',
'auto_apply': True,
'country_id': self.jc.country_id.id,
'sequence': sequence
} for sequence in range(1, 3)])
self.assertEqual(self.fp._get_fiscal_position(self.jc), fiscal_positions[0])

View file

@ -1,920 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.fields import Command
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestTaxTotals(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.tax_group1 = cls.env['account.tax.group'].create({
'name': '1',
'sequence': 1
})
cls.tax_group2 = cls.env['account.tax.group'].create({
'name': '2',
'sequence': 2
})
cls.tax_group_sub1 = cls.env['account.tax.group'].create({
'name': 'subtotals 1',
'preceding_subtotal': "PRE GROUP 1",
'sequence': 3
})
cls.tax_group_sub2 = cls.env['account.tax.group'].create({
'name': 'subtotals 2',
'preceding_subtotal': "PRE GROUP 2",
'sequence': 4
})
cls.tax_group_sub3 = cls.env['account.tax.group'].create({
'name': 'subtotals 3',
'preceding_subtotal': "PRE GROUP 1", # same as sub1, on purpose
'sequence': 5
})
cls.tax_10 = cls.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
})
cls.tax_16 = cls.env['account.tax'].create({
'name': "tax_16",
'amount_type': 'percent',
'amount': 16.0,
})
cls.tax_53 = cls.env['account.tax'].create({
'name': "tax_53",
'amount_type': 'percent',
'amount': 53.0,
})
cls.tax_17a = cls.env['account.tax'].create({
'name': "tax_17a",
'amount_type': 'percent',
'amount': 17.0,
})
cls.tax_17b = cls.tax_17a.copy({'name': "tax_17b"})
def assertTaxTotals(self, document, expected_values):
main_keys_to_ignore = {'formatted_amount_total', 'formatted_amount_untaxed'}
group_keys_to_ignore = {'group_key', 'formatted_tax_group_amount', 'formatted_tax_group_base_amount', 'hide_base_amount'}
subtotals_keys_to_ignore = {'formatted_amount'}
to_compare = document.tax_totals
for key in main_keys_to_ignore:
del to_compare[key]
for key in group_keys_to_ignore:
for groups in to_compare['groups_by_subtotal'].values():
for group in groups:
del group[key]
for key in subtotals_keys_to_ignore:
for subtotal in to_compare['subtotals']:
del subtotal[key]
self.assertEqual(to_compare, expected_values)
def _create_document_for_tax_totals_test(self, lines_data):
""" Creates and returns a new record of a model defining a tax_totals
field and using the related widget.
By default, this function creates an invoice, but it is overridden in sale
and purchase to create respectively a sale.order or a purchase.order. This way,
we can test the invoice_tax_totals from both these models in the same way as
account.move's.
:param lines_data: a list of tuple (amount, taxes), where amount is a base amount,
and taxes a recordset of account.tax objects corresponding
to the taxes to apply on this amount. Each element of the list
corresponds to a line of the document (invoice line, PO line, SO line).
"""
invoice_lines_vals = [
(0, 0, {
'name': 'line',
'display_type': 'product',
'account_id': self.company_data['default_account_revenue'].id,
'price_unit': amount,
'tax_ids': [(6, 0, taxes.ids)],
})
for amount, taxes in lines_data]
return self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-01',
'invoice_line_ids': invoice_lines_vals,
})
def test_multiple_tax_lines(self):
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
'tax_group_id': self.tax_group1.id,
})
tax_20 = self.env['account.tax'].create({
'name': "tax_20",
'amount_type': 'percent',
'amount': 20.0,
'tax_group_id': self.tax_group2.id,
})
document = self._create_document_for_tax_totals_test([
(1000, tax_10 + tax_20),
(1000, tax_10),
(1000, tax_20),
])
self.assertTaxTotals(document, {
'amount_total': 3600,
'amount_untaxed': 3000,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 200,
'tax_group_base_amount': 2000,
'tax_group_id': self.tax_group1.id,
},
{
'tax_group_name': self.tax_group2.name,
'tax_group_amount': 400,
'tax_group_base_amount': 2000,
'tax_group_id': self.tax_group2.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 3000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
# Same but both are sharing the same tax group.
tax_20.tax_group_id = self.tax_group1
document.invalidate_model(['tax_totals'])
self.assertTaxTotals(document, {
'amount_total': 3600,
'amount_untaxed': 3000,
'display_tax_base': False,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 600,
'tax_group_base_amount': 3000,
'tax_group_id': self.tax_group1.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 3000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
def test_zero_tax_lines(self):
tax_0 = self.env['account.tax'].create({
'name': "tax_0",
'amount_type': 'percent',
'amount': 0.0,
})
document = self._create_document_for_tax_totals_test([
(1000, tax_0),
])
self.assertTaxTotals(document, {
'amount_total': 1000,
'amount_untaxed': 1000,
'display_tax_base': False,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': tax_0.tax_group_id.name,
'tax_group_amount': 0,
'tax_group_base_amount': 1000,
'tax_group_id': tax_0.tax_group_id.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 1000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
def test_tax_affect_base_1(self):
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
'tax_group_id': self.tax_group1.id,
'price_include': True,
'include_base_amount': True,
})
tax_20 = self.env['account.tax'].create({
'name': "tax_20",
'amount_type': 'percent',
'amount': 20.0,
'tax_group_id': self.tax_group2.id,
})
document = self._create_document_for_tax_totals_test([
(1100, tax_10 + tax_20),
(1100, tax_10),
(1000, tax_20),
])
self.assertTaxTotals(document, {
'amount_total': 3620,
'amount_untaxed': 3000,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 200,
'tax_group_base_amount': 2000,
'tax_group_id': self.tax_group1.id,
},
{
'tax_group_name': self.tax_group2.name,
'tax_group_amount': 420,
'tax_group_base_amount': 2100,
'tax_group_id': self.tax_group2.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 3000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
# Same but both are sharing the same tax group.
tax_20.tax_group_id = self.tax_group1
document.invalidate_model(['tax_totals'])
self.assertTaxTotals(document, {
'amount_total': 3620,
'amount_untaxed': 3000,
'display_tax_base': False,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 620,
'tax_group_base_amount': 3000,
'tax_group_id': self.tax_group1.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 3000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
def test_tax_affect_base_2(self):
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
'tax_group_id': self.tax_group1.id,
'include_base_amount': True,
'sequence': 2,
})
tax_20 = self.env['account.tax'].create({
'name': "tax_20",
'amount_type': 'percent',
'amount': 20.0,
'tax_group_id': self.tax_group1.id,
'sequence': 2,
})
tax_30 = self.env['account.tax'].create({
'name': "tax_30",
'amount_type': 'percent',
'amount': 30.0,
'tax_group_id': self.tax_group2.id,
'include_base_amount': True,
'sequence': 1,
})
document = self._create_document_for_tax_totals_test([
(1000, tax_10 + tax_20),
(1000, tax_30 + tax_10),
])
self.assertTaxTotals(document, {
'amount_total': 2750,
'amount_untaxed': 2000,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 450,
'tax_group_base_amount': 2300,
'tax_group_id': self.tax_group1.id,
},
{
'tax_group_name': self.tax_group2.name,
'tax_group_amount': 300,
'tax_group_base_amount': 1000,
'tax_group_id': self.tax_group2.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 2000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
# Same but both are sharing the same tax group.
tax_30.tax_group_id = self.tax_group1
document.invalidate_model(['tax_totals'])
self.assertTaxTotals(document, {
'amount_total': 2750,
'amount_untaxed': 2000,
'display_tax_base': False,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 750,
'tax_group_base_amount': 2000,
'tax_group_id': self.tax_group1.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 2000,
}
],
'subtotals_order': ["Untaxed Amount"],
})
def test_subtotals_basic(self):
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
'tax_group_id': self.tax_group_sub1.id,
})
tax_25 = self.env['account.tax'].create({
'name': "tax_25",
'amount_type': 'percent',
'amount': 25.0,
'tax_group_id': self.tax_group_sub2.id,
})
tax_42 = self.env['account.tax'].create({
'name': "tax_42",
'amount_type': 'percent',
'amount': 42.0,
'tax_group_id': self.tax_group1.id,
})
document = self._create_document_for_tax_totals_test([
(1000, tax_10),
(1000, tax_25),
(100, tax_42),
(200, tax_42 + tax_10 + tax_25),
])
self.assertTaxTotals(document, {
'amount_total': 2846,
'amount_untaxed': 2300,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 126,
'tax_group_base_amount': 300,
'tax_group_id': self.tax_group1.id,
},
],
'PRE GROUP 1': [
{
'tax_group_name': self.tax_group_sub1.name,
'tax_group_amount': 120,
'tax_group_base_amount': 1200,
'tax_group_id': self.tax_group_sub1.id,
},
],
'PRE GROUP 2': [
{
'tax_group_name': self.tax_group_sub2.name,
'tax_group_amount': 300,
'tax_group_base_amount': 1200,
'tax_group_id': self.tax_group_sub2.id,
},
]
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 2300,
},
{
'name': "PRE GROUP 1",
'amount': 2426,
},
{
'name': "PRE GROUP 2",
'amount': 2546,
},
],
'subtotals_order': ["Untaxed Amount", "PRE GROUP 1", "PRE GROUP 2"],
})
def test_after_total_mix(self):
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10.0,
'tax_group_id': self.tax_group_sub3.id,
})
tax_25 = self.env['account.tax'].create({
'name': "tax_25",
'amount_type': 'percent',
'amount': -25.0,
'tax_group_id': self.tax_group_sub2.id,
})
tax_42 = self.env['account.tax'].create({
'name': "tax_42",
'amount_type': 'percent',
'amount': 42.0,
'tax_group_id': self.tax_group_sub1.id,
})
tax_30 = self.env['account.tax'].create({
'name': "tax_30",
'amount_type': 'percent',
'amount': 30.0,
'tax_group_id': self.tax_group1.id,
})
document = self._create_document_for_tax_totals_test([
(100, tax_10),
(100, tax_25 + tax_42 + tax_30),
(200, tax_10 + tax_25),
(1000, tax_30),
(100, tax_30 + tax_10)
])
self.assertTaxTotals(document, {
'amount_total': 1867,
'amount_untaxed': 1500,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [
{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 360,
'tax_group_base_amount': 1200,
'tax_group_id': self.tax_group1.id,
},
],
'PRE GROUP 1': [
{
'tax_group_name': self.tax_group_sub1.name,
'tax_group_amount': 42,
'tax_group_base_amount': 100,
'tax_group_id': self.tax_group_sub1.id,
},
{
'tax_group_name': self.tax_group_sub3.name,
'tax_group_amount': 40,
'tax_group_base_amount': 400,
'tax_group_id': self.tax_group_sub3.id,
},
],
'PRE GROUP 2': [
{
'tax_group_name': self.tax_group_sub2.name,
'tax_group_amount': -75,
'tax_group_base_amount': 300,
'tax_group_id': self.tax_group_sub2.id,
},
],
},
'subtotals': [
{
'name': "Untaxed Amount",
'amount': 1500,
},
{
'name': "PRE GROUP 1",
'amount': 1860,
},
{
'name': "PRE GROUP 2",
'amount': 1942,
},
],
'subtotals_order': ["Untaxed Amount", "PRE GROUP 1", "PRE GROUP 2"],
})
def test_discounted_tax(self):
tax_21_exempted = self.env['account.tax'].create({
'name': "tax_21_exempted",
'amount_type': 'group',
'amount': 2.0,
'tax_group_id': self.tax_group1.id,
'children_tax_ids': [
Command.create({
'name': "tax_exempt",
'amount_type': 'percent',
'amount': -2.0,
'include_base_amount': True,
'tax_group_id': self.tax_group_sub1.id,
'sequence': 1,
}),
Command.create({
'name': "tax_21",
'amount_type': 'percent',
'amount': 21.0,
'tax_group_id': self.tax_group_sub2.id,
'sequence': 2,
}),
Command.create({
'name': "tax_reapply",
'amount_type': 'percent',
'amount': 2.0,
'is_base_affected': False,
'tax_group_id': self.tax_group_sub3.id,
'sequence': 3,
}),
]
})
self.tax_group_sub1.preceding_subtotal = "Tax exemption"
self.tax_group_sub2.preceding_subtotal = "Tax application"
self.tax_group_sub3.preceding_subtotal = "Reapply amount"
document = self._create_document_for_tax_totals_test([
(1000 / 0.98, tax_21_exempted),
])
self.assertTaxTotals(document, {
'amount_total': 1230.41,
'amount_untaxed': 1020.41,
'display_tax_base': True,
'groups_by_subtotal': {
"Reapply amount": [{
'tax_group_name': self.tax_group_sub3.name,
'tax_group_amount': 20.41,
'tax_group_base_amount': 1020.41,
'tax_group_id': self.tax_group_sub3.id,
}],
"Tax application": [{
'tax_group_name': self.tax_group_sub2.name,
'tax_group_amount': 210.0,
'tax_group_base_amount': 1000.0,
'tax_group_id': self.tax_group_sub2.id,
}],
"Tax exemption": [{
'tax_group_name': self.tax_group_sub1.name,
'tax_group_amount': -20.41,
'tax_group_base_amount': 1020.41,
'tax_group_id': self.tax_group_sub1.id,
}],
},
'subtotals': [{
'name': "Tax exemption",
'amount': 1020.41,
}, {
'name': "Tax application",
'amount': 1000.00,
}, {
'name': "Reapply amount",
'amount': 1210.00,
}],
'subtotals_order': ["Tax exemption", "Tax application", "Reapply amount"],
})
def test_invoice_grouped_taxes_with_tax_group(self):
""" A tax of type group with a tax_group_id being the same as one of the children tax shouldn't affect the
result of the _prepare_tax_totals.
"""
tax_10_withheld = self.env['account.tax'].create({
'name': "tax_10_withheld",
'amount_type': 'group',
'tax_group_id': self.tax_group1.id,
'children_tax_ids': [
Command.create({
'name': "tax_withheld",
'amount_type': 'percent',
'amount': -47,
'tax_group_id': self.tax_group_sub1.id,
'sequence': 1,
}),
Command.create({
'name': "tax_10",
'amount_type': 'percent',
'amount': 10,
'tax_group_id': self.tax_group1.id,
'sequence': 2,
}),
]
})
self.tax_group_sub1.preceding_subtotal = "Tax withholding"
document = self._create_document_for_tax_totals_test([
(100, tax_10_withheld),
])
self.assertTaxTotals(document, {
'amount_total': 63,
'amount_untaxed': 100,
'display_tax_base': True,
'groups_by_subtotal': {
'Untaxed Amount': [{
'tax_group_name': self.tax_group1.name,
'tax_group_amount': 10,
'tax_group_base_amount': 100,
'tax_group_id': self.tax_group1.id,
}],
"Tax withholding": [{
'tax_group_name': self.tax_group_sub1.name,
'tax_group_amount': -47,
'tax_group_base_amount': 100,
'tax_group_id': self.tax_group_sub1.id,
}],
},
'subtotals': [{
'name': "Untaxed Amount",
'amount': 100,
}, {
'name': "Tax withholding",
'amount': 110,
}],
'subtotals_order': ["Untaxed Amount", "Tax withholding"],
})
def test_taxtotals_with_different_tax_rounding_methods(self):
def run_case(rounding_line, lines, expected_tax_group_amounts):
self.env.company.tax_calculation_rounding_method = rounding_line
document = self._create_document_for_tax_totals_test(lines)
tax_amounts = document.tax_totals['groups_by_subtotal']['Untaxed Amount']
if len(expected_tax_group_amounts) != len(tax_amounts):
self.fail("Wrong number of values to compare.")
for tax_amount, expected in zip(tax_amounts, expected_tax_group_amounts):
actual = tax_amount['tax_group_amount']
if document.currency_id.compare_amounts(actual, expected) != 0:
self.fail(f'{document.currency_id.round(actual)} != {expected}')
# one line, two taxes
lines = [
(100.41, self.tax_16 + self.tax_53),
]
run_case('round_per_line', lines, [69.29])
run_case('round_globally', lines, [69.29])
# two lines, different taxes
lines = [
(50.4, self.tax_17a),
(47.21, self.tax_17b),
]
run_case('round_per_line', lines, [16.60])
run_case('round_globally', lines, [16.60])
# two lines, same tax
lines = [
(50.4, self.tax_17a),
(47.21, self.tax_17a),
]
run_case('round_per_line', lines, [16.60])
run_case('round_globally', lines, [16.59])
lines = [
(54.45, self.tax_10),
(600, self.tax_10),
(-500, self.tax_10),
]
run_case('round_per_line', lines, [15.45])
run_case('round_globally', lines, [15.45])
def test_cash_rounding_amount_total_rounded(self):
tax_15 = self.env['account.tax'].create({
'name': "tax_15",
'amount_type': 'percent',
'tax_group_id': self.tax_group1.id,
'amount': 15.0,
})
tax_10 = self.env['account.tax'].create({
'name': "tax_10",
'amount_type': 'percent',
'tax_group_id': self.tax_group2.id,
'amount': 10.0,
})
cash_rounding_biggest_tax = self.env['account.cash.rounding'].create({
'name': 'biggest tax Rounding HALF-UP',
'rounding': 1,
'strategy': 'biggest_tax',
'rounding_method': 'HALF-UP',
})
cash_rounding_add_invoice_line = self.env['account.cash.rounding'].create({
'name': 'add invoice line Rounding HALF-UP',
'rounding': 1,
'strategy': 'add_invoice_line',
'profit_account_id': self.company_data['default_account_revenue'].id,
'loss_account_id': self.company_data['default_account_expense'].id,
'rounding_method': 'HALF-UP',
})
for move_type in ['out_invoice', 'in_invoice']:
move = self.env['account.move'].create({
'move_type': move_type,
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-01',
'invoice_line_ids': [
Command.create({
'name': 'line',
'display_type': 'product',
'price_unit': 378,
'tax_ids': [Command.set(tax_15.ids)],
}),
Command.create({
'name': 'line',
'display_type': 'product',
'price_unit': 100,
'tax_ids': [Command.set(tax_10.ids)],
})
],
})
move.invoice_cash_rounding_id = cash_rounding_biggest_tax
self.assertEqual(move.tax_totals['groups_by_subtotal']['Untaxed Amount'][0]['tax_group_amount'], 57)
self.assertEqual(move.tax_totals['groups_by_subtotal']['Untaxed Amount'][1]['tax_group_amount'], 10)
self.assertEqual(move.tax_totals['amount_total'], 545)
move.invoice_cash_rounding_id = cash_rounding_add_invoice_line
self.assertEqual(move.tax_totals['groups_by_subtotal']['Untaxed Amount'][0]['tax_group_amount'], 56.7)
self.assertEqual(move.tax_totals['groups_by_subtotal']['Untaxed Amount'][1]['tax_group_amount'], 10)
self.assertEqual(move.tax_totals['rounding_amount'], 0.3)
self.assertEqual(move.tax_totals['amount_total'], 544.7)
self.assertEqual(move.tax_totals['amount_total_rounded'], 545)
def test_recompute_cash_rounding_lines(self):
# if rounding_method is changed then rounding shouldn't be recomputed in posted invoices
cash_rounding_add_invoice_line = self.env['account.cash.rounding'].create({
'name': 'Add invoice line Rounding UP',
'rounding': 1,
'strategy': 'add_invoice_line',
'profit_account_id': self.company_data['default_account_revenue'].id,
'loss_account_id': self.company_data['default_account_expense'].id,
'rounding_method': 'UP',
})
moves_rounding = {}
moves = self.env['account.move']
for move_type in ['out_invoice', 'in_invoice']:
move = self.env['account.move'].create({
'move_type': move_type,
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-01',
'invoice_cash_rounding_id': cash_rounding_add_invoice_line.id,
'invoice_line_ids': [
Command.create({
'name': 'line',
'display_type': 'product',
'price_unit': 99.5,
})
],
})
moves_rounding[move] = sum(move.line_ids.filtered(lambda line: line.display_type == 'rounding').mapped('balance'))
moves += move
moves.action_post()
cash_rounding_add_invoice_line.rounding_method = 'DOWN'
# check if rounding is recomputed
moves.to_check = True
for move in moves_rounding:
self.assertEqual(sum(move.line_ids.filtered(lambda line: line.display_type == 'rounding').mapped('balance')), moves_rounding[move])
def test_recompute_cash_rounding_lines_multi_company(self):
"""
Ensure that when a move is created with cash rounding that will add an invoice line, the cash rounding accounts
will be that of the move's company and not the user's company.
"""
cash_rounding_tbl = self.env['account.cash.rounding'].with_company(self.company_data["company"])
cash_rounding_add_invoice_line = cash_rounding_tbl.create({
'name': 'Add invoice line Rounding UP',
'rounding': 1,
'rounding_method': 'UP',
'strategy': 'add_invoice_line',
'profit_account_id': self.company_data['default_account_revenue'].id,
'loss_account_id': self.company_data['default_account_expense'].id,
})
cash_rounding_add_invoice_line.with_company(self.company_data_2["company"]).write({
'profit_account_id': self.company_data_2['default_account_revenue'].id,
'loss_account_id': self.company_data_2['default_account_expense'].id,
})
move = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-01',
'company_id': self.company_data_2['company'].id,
'invoice_cash_rounding_id': cash_rounding_add_invoice_line.id,
'invoice_line_ids': [
Command.create({
'name': 'line',
'display_type': 'product',
'price_unit': 99.5,
})
],
})
rounding_line_account = move.line_ids.filtered(lambda line: line.display_type == 'rounding').mapped('account_id')
self.assertEqual(rounding_line_account, self.company_data_2['default_account_revenue'])
def test_cash_rounding_amount_total_rounded_foreign_currency(self):
tax_15 = self.env['account.tax'].create({
'name': "tax_15",
'amount_type': 'percent',
'amount': 15.0,
})
cash_rounding = self.env['account.cash.rounding'].create({
'name': 'Rounding HALF-UP',
'rounding': 10,
'strategy': 'biggest_tax',
'rounding_method': 'HALF-UP',
})
self.env['res.currency.rate'].create({
'name': '2023-01-01',
'rate': 0.2,
'currency_id': self.currency_data['currency'].id,
'company_id': self.env.company.id,
})
for move_type in ['out_invoice', 'in_invoice']:
move = self.env['account.move'].create({
'move_type': move_type,
'partner_id': self.partner_a.id,
'invoice_date': '2023-01-01',
'currency_id': self.currency_data['currency'].id,
'invoice_line_ids': [
Command.create({
'name': 'line',
'display_type': 'product',
'price_unit': 100,
'tax_ids': [tax_15.id],
})
]
})
move.invoice_cash_rounding_id = cash_rounding
self.assertEqual(move.tax_totals['amount_total'], 120)

View file

@ -9,10 +9,9 @@ from odoo.tests import tagged, Form
class TestInvoiceTaxes(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.company_data['company'].country_id = cls.env.ref('base.us')
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('HRK')
cls.percent_tax_1 = cls.env['account.tax'].create({
'name': '21%',
@ -24,7 +23,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'name': '21% incl',
'amount_type': 'percent',
'amount': 21,
'price_include': True,
'price_include_override': 'tax_included',
'include_base_amount': True,
'sequence': 20,
})
@ -38,7 +37,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'name': '5% incl',
'amount_type': 'percent',
'amount': 5,
'price_include': True,
'price_include_override': 'tax_included',
'include_base_amount': True,
'sequence': 40,
})
@ -76,8 +75,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
}),
],
})
tax_tags = tax_report_line.expression_ids._get_matching_tags()
cls.tax_tag_pos, cls.tax_tag_neg = tax_tags.sorted('tax_negate')
cls.tax_tag = tax_report_line.expression_ids._get_matching_tags()
base_report_line = cls.env['account.report.line'].create({
'name': 'base_test_tax_report_line',
@ -91,10 +89,9 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
}),
],
})
base_tags = base_report_line.expression_ids._get_matching_tags()
cls.base_tag_pos, cls.base_tag_neg = base_tags.sorted('tax_negate')
cls.base_tag = base_report_line.expression_ids._get_matching_tags()
def _create_invoice(self, taxes_per_line, inv_type='out_invoice', currency_id=False, invoice_payment_term_id=False):
def _create_invoice_taxes_per_line(self, taxes_per_line, inv_type='out_invoice', currency_id=False, invoice_payment_term_id=False):
''' Create an invoice on the fly.
:param taxes_per_line: A list of tuple (price_unit, account.tax recordset)
@ -125,10 +122,10 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
--------------------------------------------
21% | / | 100 | 21
'''
invoice = self._create_invoice([(100, self.env['account.tax'])])
invoice = self._create_invoice_taxes_per_line([(100, self.env['account.tax'])])
invoice.invoice_line_ids[0].tax_ids = self.percent_tax_1
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [
{'name': self.percent_tax_1.name, 'tax_base_amount': 100, 'balance': -21, 'tax_ids': []},
{'name': self.percent_tax_1.name, 'tax_base_amount': -100, 'balance': -21, 'tax_ids': []},
])
def test_one_tax_per_line(self):
@ -146,16 +143,16 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
21% incl | / | 100 | 21
12% | / | 100 | 12
'''
invoice = self._create_invoice([
invoice = self._create_invoice_taxes_per_line([
(100, self.percent_tax_1),
(121, self.percent_tax_1_incl),
(100, self.percent_tax_2),
])
invoice.action_post()
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id').sorted(lambda x: x.name), [
{'name': self.percent_tax_2.name, 'tax_base_amount': 100, 'balance': -12, 'tax_ids': []},
{'name': self.percent_tax_1.name, 'tax_base_amount': 100, 'balance': -21, 'tax_ids': []},
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100, 'balance': -21, 'tax_ids': []},
{'name': self.percent_tax_2.name, 'tax_base_amount': -100, 'balance': -12, 'tax_ids': []},
{'name': self.percent_tax_1.name, 'tax_base_amount': -100, 'balance': -21, 'tax_ids': []},
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': -100, 'balance': -21, 'tax_ids': []},
])
def test_affecting_base_amount(self):
@ -172,14 +169,14 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
12% | / | 121 | 14.52
12% | / | 100 | 12
'''
invoice = self._create_invoice([
invoice = self._create_invoice_taxes_per_line([
(121, self.percent_tax_1_incl + self.percent_tax_2),
(100, self.percent_tax_2),
])
invoice.action_post()
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id').sorted(lambda x: -x.balance), [
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100, 'balance': -21, 'tax_ids': [self.percent_tax_2.id]},
{'name': self.percent_tax_2.name, 'tax_base_amount': 221, 'balance': -26.52, 'tax_ids': []},
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': -100, 'balance': -21, 'tax_ids': [self.percent_tax_2.id]},
{'name': self.percent_tax_2.name, 'tax_base_amount': -221, 'balance': -26.52, 'tax_ids': []},
])
def test_group_of_taxes(self):
@ -196,15 +193,15 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
12% | 21% incl | 121 | 14.52
12% | / | 100 | 12
'''
invoice = self._create_invoice([
invoice = self._create_invoice_taxes_per_line([
(121, self.group_tax),
(100, self.percent_tax_2),
])
invoice.action_post()
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id').sorted('balance'), [
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': 100.0, 'balance': -21.0, 'tax_ids': [self.percent_tax_2.id]},
{'name': self.percent_tax_2.name, 'tax_base_amount': 121.0, 'balance': -14.52, 'tax_ids': []},
{'name': self.percent_tax_2.name, 'tax_base_amount': 100.0, 'balance': -12.0, 'tax_ids': []},
{'name': self.percent_tax_1_incl.name, 'tax_base_amount': -100.0, 'balance': -21.0, 'tax_ids': [self.percent_tax_2.id]},
{'name': self.percent_tax_2.name, 'tax_base_amount': -121.0, 'balance': -14.52, 'tax_ids': []},
{'name': self.percent_tax_2.name, 'tax_base_amount': -100.0, 'balance': -12.0, 'tax_ids': []},
])
def _create_tax_tag(self, tag_name):
@ -271,7 +268,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
})
# Test invoice repartition
invoice = self._create_invoice([(100, tax)], inv_type='out_invoice')
invoice = self._create_invoice_taxes_per_line([(100, tax)], inv_type='out_invoice')
invoice.action_post()
self.assertEqual(len(invoice.line_ids), 4, "There should be 4 account move lines created for the invoice: payable, base and 2 tax lines")
@ -287,7 +284,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
self.assertEqual(inv_tax_lines.filtered(lambda x: x.account_id == account_2).tax_tag_ids, inv_tax_tag_90, "Tax line on account 2 should have 90% tag")
# Test refund repartition
refund = self._create_invoice([(100, tax)], inv_type='out_refund')
refund = self._create_invoice_taxes_per_line([(100, tax)], inv_type='out_refund')
refund.action_post()
self.assertEqual(len(refund.line_ids), 4, "There should be 4 account move lines created for the refund: payable, base and 2 tax lines")
@ -319,10 +316,10 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'type_tax_use': 'sale',
'amount_type': 'division',
'amount': 100,
'price_include': True,
'price_include_override': 'tax_included',
'include_base_amount': True,
})
invoice = self._create_invoice([(100, sale_tax)])
invoice = self._create_invoice_taxes_per_line([(100, sale_tax)])
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
'name': sale_tax.name,
'tax_base_amount': 0.0,
@ -348,21 +345,21 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'invoice_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_pos.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_pos.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
'refund_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_neg.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_neg.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
})
@ -393,9 +390,9 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
move = move_form.save()
self.assertRecordValues(move.line_ids.sorted('balance'), [
{'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': ref_tax_rep_ln.id, 'tax_tag_invert': False},
{'balance': 1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': ref_tax_rep_ln.id},
{'balance': 1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False},
])
# === Tax in credit ===
@ -420,9 +417,9 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
move = move_form.save()
self.assertRecordValues(move.line_ids.sorted('balance'), [
{'balance': -1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': True},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': inv_tax_rep_ln.id, 'tax_tag_invert': True},
{'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': -1000.0, 'tax_ids': sale_tax.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': -1000, 'tax_repartition_line_id': inv_tax_rep_ln.id},
{'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False},
])
def test_misc_journal_entry_tax_tags_purchase(self):
@ -434,21 +431,21 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'invoice_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_pos.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_pos.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
'refund_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_neg.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_neg.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
})
@ -478,9 +475,9 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
move = move_form.save()
self.assertRecordValues(move.line_ids.sorted('balance'), [
{'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': inv_tax_rep_ln.id, 'tax_tag_invert': False},
{'balance': 1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': -1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': inv_tax_rep_ln.id},
{'balance': 1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False},
])
# === Tax in credit ===
@ -505,9 +502,9 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
move = move_form.save()
self.assertRecordValues(move.line_ids.sorted('balance'), [
{'balance': -1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': True},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000, 'tax_repartition_line_id': ref_tax_rep_ln.id, 'tax_tag_invert': True},
{'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False, 'tax_tag_invert': False},
{'balance': -1000.0, 'tax_ids': purch_tax.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0, 'tax_repartition_line_id': False},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': -1000, 'tax_repartition_line_id': ref_tax_rep_ln.id},
{'balance': 1100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0, 'tax_repartition_line_id': False},
])
def test_misc_entry_tax_group_signs(self):
@ -525,11 +522,11 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'invoice_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_pos.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_pos.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
'refund_repartition_line_ids': [
@ -550,11 +547,11 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'refund_repartition_line_ids': [
(0, 0, {
'repartition_type': 'base',
'tag_ids': [(6, 0, self.base_tag_neg.ids)],
'tag_ids': [(6, 0, self.base_tag.ids)],
}),
(0, 0, {
'repartition_type': 'tax',
'tag_ids': [(6, 0, self.tax_tag_neg.ids)],
'tag_ids': [(6, 0, self.tax_tag.ids)],
}),
],
})
@ -588,37 +585,37 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
# Sale tax on debit: use refund repartition
debit_sale_move = _create_misc_operation(sale_group, 'debit')
self.assertRecordValues(debit_sale_move.line_ids.sorted('balance'), [
{'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000},
{'balance': 1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0},
{'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': 1000},
{'balance': 1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0},
])
# Sale tax on credit: use invoice repartition
credit_sale_move = _create_misc_operation(sale_group, 'credit')
self.assertRecordValues(credit_sale_move.line_ids.sorted('balance'), [
{'balance': -1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000},
{'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': -1000.0, 'tax_ids': sale_group.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': -1000},
{'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': -1000},
{'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
])
# Purchase tax on debit: use invoice repartition
debit_purchase_move = _create_misc_operation(purchase_group, 'debit')
self.assertRecordValues(debit_purchase_move.line_ids.sorted('balance'), [
{'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_pos.ids, 'tax_base_amount': 1000},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': 1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag_pos.ids, 'tax_base_amount': 0},
{'balance': -1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': 50.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': 1000},
{'balance': 100.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': 1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0},
])
# Purchase tax on credit: use refund repartition
credit_purchase_move = _create_misc_operation(purchase_group, 'credit')
self.assertRecordValues(credit_purchase_move.line_ids.sorted('balance'), [
{'balance': -1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag_neg.ids, 'tax_base_amount': 0},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag_neg.ids, 'tax_base_amount': 1000},
{'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 1000},
{'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
{'balance': -1000.0, 'tax_ids': purchase_group.ids, 'tax_tag_ids': self.base_tag.ids, 'tax_base_amount': 0},
{'balance': -100.0, 'tax_ids': [], 'tax_tag_ids': self.tax_tag.ids, 'tax_base_amount': -1000},
{'balance': -50.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': -1000},
{'balance': 1150.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_base_amount': 0},
])
def test_tax_calculation_foreign_currency_large_quantity(self):
@ -631,15 +628,15 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
self.env['res.currency.rate'].create({
'name': '2018-01-01',
'rate': 1.1726,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'company_id': self.env.company.id,
})
self.currency_data['currency'].rounding = 0.05
self.other_currency.rounding = 0.05
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_date': '2018-01-01',
'date': '2018-01-01',
'invoice_line_ids': [(0, 0, {
@ -651,7 +648,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
})
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
'tax_base_amount': 48098.24, # 20000 * 2.82 / 1.1726
'tax_base_amount': -48098.24, # 20000 * 2.82 / 1.1726
'credit': 10100.63, # tax_base_amount * 0.21
}])
@ -660,31 +657,31 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
self.env['res.currency.rate'].create({
'name': '2018-01-01',
'rate': 0.654065014,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'company_id': self.env.company.id,
})
self.currency_data['currency'].rounding = 0.05
self.other_currency.rounding = 0.05
invoice = self._create_invoice([
invoice = self._create_invoice_taxes_per_line([
(5, self.percent_tax_3_incl),
(10, self.percent_tax_3_incl),
(50, self.percent_tax_3_incl),
], currency_id=self.currency_data['currency'], invoice_payment_term_id=self.pay_terms_a)
], currency_id=self.other_currency, invoice_payment_term_id=self.pay_terms_a)
invoice.action_post()
def test_tax_calculation_multi_currency(self):
self.env['res.currency.rate'].create({
'name': '2018-01-01',
'rate': 0.273748,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'company_id': self.env.company.id,
})
self.currency_data['currency'].rounding = 0.01
self.other_currency.rounding = 0.01
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_date': '2018-01-01',
'date': '2018-01-01',
'invoice_line_ids': [(0, 0, {
@ -696,59 +693,24 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
})
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
'tax_base_amount': 567.38, # 155.32 * 1 / (1 / 0.273748)
'balance': -119.16, # tax_base_amount * 0.21
'tax_base_amount': -567.38, # 155.32 * 1 / (1 / 0.273748)
'balance': -119.15, # tax_base_amount * 0.21
}])
self.assertRecordValues(invoice.line_ids.filtered(lambda l: not l.name), [{
'balance': 686.54,
'balance': 686.53,
}])
with Form(invoice) as invoice_form:
invoice_form.currency_id = self.currency_data['currency']
invoice_form.currency_id = self.other_currency
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
'tax_base_amount': 567.38,
'balance': -119.16,
'tax_base_amount': -567.38,
'balance': -119.15,
}])
self.assertRecordValues(invoice.line_ids.filtered(lambda l: l.account_id.account_type == 'asset_receivable'), [{
'balance': 686.54,
}])
def test_tax_calculation_multi_currency_100_included_tax(self):
self.env['res.currency.rate'].create({
'name': '2018-01-01',
'rate': 0.273748,
'currency_id': self.currency_data['currency'].id,
'company_id': self.env.company.id,
})
self.currency_data['currency'].rounding = 0.01
tax = self.env['account.tax'].create({
'name': 'tax_100',
'amount_type': 'division',
'amount': 100,
'price_include': True,
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'currency_id': self.currency_data['currency'].id,
'invoice_date': '2018-01-01',
'date': '2018-01-01',
'invoice_line_ids': [(0, 0, {
'name': 'xxxx',
'quantity': 1,
'price_unit': 100.00,
'tax_ids': [(6, 0, tax.ids)],
})]
})
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
'tax_base_amount': 0.0,
'balance': -365.3, # 100 * (1 / 0.273748)
'balance': 686.53,
}])
def test_fixed_tax_with_zero_price(self):
@ -757,7 +719,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'amount_type': 'fixed',
'amount': 5,
})
invoice = self._create_invoice([
invoice = self._create_invoice_taxes_per_line([
(0, fixed_tax),
])
self.assertRecordValues(invoice.line_ids.filtered('tax_line_id'), [{
@ -770,6 +732,64 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'debit': 0,
}])
def test_tax_repartition_lines_dispatch_amount_1(self):
""" Ensure the tax amount is well dispatched to the repartition lines and the rounding errors are well managed.
The 5% tax is applied on 1 so the total tax amount should be 0.05.
However, there are 10 tax repartition lines of 0.05 * 0.1 = 0.005 that will be rounded to 0.01.
The test checks the tax amount doesn't become 10 * 0.01 = 0.1 instead of 0.05.
"""
base_tax_rep = Command.create({'repartition_type': 'base', 'factor_percent': 100.0})
tax_reps = [Command.create({'repartition_type': 'tax', 'factor_percent': 10.0})] * 10
tax = self.env['account.tax'].create({
'name': "test_tax_repartition_lines_dispatch_amount_1",
'amount_type': 'percent',
'amount': 5.0,
'invoice_repartition_line_ids': [base_tax_rep] + tax_reps,
'refund_repartition_line_ids': [base_tax_rep] + tax_reps,
})
invoice = self._create_invoice_taxes_per_line([(1, tax)])
self.assertRecordValues(invoice, [{
'amount_untaxed': 1.0,
'amount_tax': 0.05,
'amount_total': 1.05,
}])
self.assertRecordValues(
invoice.line_ids.filtered('tax_line_id').sorted('balance'),
[{'balance': -0.01}] * 5,
)
def test_tax_repartition_lines_dispatch_amount_2(self):
""" Ensure the tax amount is well dispatched to the repartition lines and the rounding errors are well managed.
The 5% tax is applied on 1 so the total tax amount should be 0.05 but the distribution is 100 - 100 = 0%.
So at the end, the tax amount is 0.
However, there are 10 positive tax repartition lines of 0.05 * 0.1 = 0.005 that will be rounded to 0.01
and one negative repartition line of 50% and 2 of 25% that will give respectively 0.025 ~= 0.03 and 0.0125 ~= 0.01.
The test checks the tax amount is still zero at the end.
"""
base_tax_rep = Command.create({'repartition_type': 'base', 'factor_percent': 100.0})
plus_tax_reps = [Command.create({'repartition_type': 'tax', 'factor_percent': 10.0})] * 10
neg_tax_reps = [
Command.create({'repartition_type': 'tax', 'factor_percent': percent})
for percent in (-50, -25, -25)
]
tax = self.env['account.tax'].create({
'name': "test_tax_repartition_lines_dispatch_amount_2",
'amount_type': 'percent',
'amount': 5.0,
'invoice_repartition_line_ids': [base_tax_rep] + plus_tax_reps + neg_tax_reps,
'refund_repartition_line_ids': [base_tax_rep] + plus_tax_reps + neg_tax_reps,
})
invoice = self._create_invoice_taxes_per_line([(1, tax)], inv_type='out_refund')
self.assertRecordValues(invoice, [{
'amount_untaxed': 1.0,
'amount_tax': 0.0,
'amount_total': 1.0,
}])
self.assertRecordValues(
invoice.line_ids.filtered('tax_line_id').sorted('balance'),
[{'balance': -0.03}] + [{'balance': -0.01}] * 2 + [{'balance': 0.01}] * 5,
)
def test_tax_line_amount_currency_modification_auto_balancing(self):
date = '2017-01-01'
move = self.env['account.move'].create({
@ -777,10 +797,10 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'date': date,
'partner_id': self.partner_a.id,
'invoice_date': date,
'currency_id': self.currency_data['currency'].id,
'currency_id': self.other_currency.id,
'invoice_payment_term_id': self.pay_terms_a.id,
'invoice_line_ids': [
(0, None, {
Command.create({
'name': self.product_a.name,
'product_id': self.product_a.id,
'product_uom_id': self.product_a.uom_id.id,
@ -788,7 +808,7 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
'price_unit': 1000,
'tax_ids': self.product_a.taxes_id.ids,
}),
(0, None, {
Command.create({
'name': self.product_b.name,
'product_id': self.product_b.id,
'product_uom_id': self.product_b.uom_id.id,
@ -816,3 +836,117 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon):
self.assertRecordValues(receivable_line, [
{'amount_currency': 1410.02, 'balance': 705.02},
])
def test_product_account_tags(self):
product_tag = self.env['account.account.tag'].create({
'name': "Pikachu",
'applicability': 'products',
})
self.product_a.account_tag_ids = [Command.set(product_tag.ids)]
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'date': '2025-01-01',
'partner_id': self.partner_a.id,
'invoice_line_ids': [
Command.create({
'name': 'test with product tag',
'product_id': self.product_a.id,
'price_unit': 1000,
'tax_ids': self.percent_tax_1.ids,
}),
Command.create({
'name': 'test with product tag',
'product_id': self.product_b.id,
'price_unit': 100,
'tax_ids': self.percent_tax_1.ids,
}),
Command.create({
'name': 'test without product tag',
'product_id': self.product_b.id,
'price_unit': 200,
'tax_ids': self.percent_tax_2.ids,
}),
]
})
invoice.action_post()
self.assertRecordValues(
invoice.line_ids,
[
{'tax_ids': self.percent_tax_1.ids, 'tax_line_id': False, 'tax_tag_ids': product_tag.ids, 'credit': 1000, 'debit': 0},
{'tax_ids': self.percent_tax_1.ids, 'tax_line_id': False, 'tax_tag_ids': [], 'credit': 100, 'debit': 0},
{'tax_ids': self.percent_tax_2.ids, 'tax_line_id': False, 'tax_tag_ids': [], 'credit': 200, 'debit': 0},
{'tax_ids': [], 'tax_line_id': self.percent_tax_1.id, 'tax_tag_ids': product_tag.ids, 'credit': 210, 'debit': 0},
{'tax_ids': [], 'tax_line_id': self.percent_tax_1.id, 'tax_tag_ids': [], 'credit': 21, 'debit': 0},
{'tax_ids': [], 'tax_line_id': self.percent_tax_2.id, 'tax_tag_ids': [], 'credit': 24, 'debit': 0},
{'tax_ids': [], 'tax_line_id': False, 'tax_tag_ids': [], 'credit': 0, 'debit': 1555},
],
)
def test_fiscal_position_tax_mapping_with_inactive_tax(self):
""" Test that inactive taxes are not mapped by fiscal positions """
fp = self.env['account.fiscal.position'].create({'name': 'FP'})
src_tax = self.company_data['default_tax_sale']
active_tax = self.percent_tax_1.copy({
'fiscal_position_ids': [Command.set(fp.ids)],
'original_tax_ids': [Command.set(src_tax.ids)],
})
self.percent_tax_2.copy({
'active': False,
'fiscal_position_ids': [Command.set(fp.ids)],
'original_tax_ids': [Command.set(src_tax.ids)],
})
self.partner_a.property_account_position_id = fp
move = self.init_invoice('out_invoice', self.partner_a, post=True, products=[self.product])
line = move.invoice_line_ids
self.assertEqual(line.tax_ids, active_tax, f"The tax should be {active_tax.name}, but is is currently {line.tax_ids.name}")
def test_multiple_onchange_product_and_price(self):
"""
This test checks that the totals are computed correctly when an onchange is executed
with "price_unit" before "product_id" in the values.
This test covers a UI issue where the totals were not updated when the price was changed,
then the product and finally the price again.
The issue was only occuring between the change of value and the next save.
That's why the test is using the onchange method directly instead of using a Form.
"""
invoice = self.init_invoice('out_invoice', products=self.product_a)
self.assertEqual(invoice.tax_totals['base_amount'], 1000.0)
self.assertEqual(invoice.tax_totals['total_amount'], 1150.0)
# The onchange is executed directly to simulate the following flow:
# 1) unit price is changed to any value
# 2) product is changed to "Product B"
# 3) unit price is changed to 2000.0
results = invoice.onchange(
{'invoice_line_ids': [Command.update(invoice.invoice_line_ids[0].id, {'price_unit': 2000.0, 'product_id': self.product_b.id})]},
['invoice_line_ids'],
{"invoice_line_ids": {}, 'tax_totals': {}}
)
self.assertEqual(results['value']['tax_totals']['base_amount'], 2000.0)
self.assertEqual(results['value']['tax_totals']['total_amount'], 2600.0)
def test_tax_mapping_with_tax_fiscal_position_set_to_all(self):
"""
A tax without any fiscal positions assigned should be applicable to
any fiscal position.
This test ensures that when a single fiscal position exists and a tax
has no fiscal position restriction, the tax is correctly applied to
invoice lines.
"""
self.env['account.fiscal.position'].search([]).action_archive()
default_tax = self.company_data['default_tax_sale']
self.env['account.fiscal.position'].create({
'name': 'FP',
'auto_apply': True,
'country_id': self.env.company.country_id.id,
'tax_ids': default_tax.ids,
})
self.assertEqual(self.product.taxes_id, default_tax)
default_tax.fiscal_position_ids = False
self.partner_a.country_id = self.env.company.country_id.id
move = self._create_invoice_one_line(product_id=self.product, partner_id=self.partner_a, post=True)
line = move.invoice_line_ids
self.assertEqual(line.tax_ids, default_tax)

View file

@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
import base64
import io
from PyPDF2 import PdfFileReader, PdfFileWriter
import re
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import RedirectWarning
from odoo.tools import pdf
from odoo.tests import tagged
from odoo.tools import file_open
from odoo.tools import file_open, mute_logger
from odoo.tools.pdf import PdfFileReader, PdfFileWriter
@tagged('post_install', '-at_install')
@ -44,6 +44,8 @@ class TestIrActionsReport(AccountTestInvoicingCommon):
test_record_report = self.env['ir.actions.report'].with_context(force_report_rendering=True)._render_qweb_pdf('account.action_account_original_vendor_bill', res_ids=in_invoice_1.id)
self.assertTrue(test_record_report, "The PDF should have been generated")
# Document synchronization being enabled, avoid a warning when computing the number of page of the corrupted pdf.
@mute_logger('odoo.addons.documents.models.documents_document')
def test_download_with_encrypted_pdf(self):
"""
Same as test_download_one_corrupted_pdf
@ -57,17 +59,20 @@ class TestIrActionsReport(AccountTestInvoicingCommon):
for page_num in range(pdf_reader.getNumPages()):
pdf_writer.addPage(pdf_reader.getPage(page_num))
# Encrypt the PDF
pdf_writer.encrypt('', use_128bit=True)
pdf_writer.encrypt('')
# Get the binary
output_buffer = io.BytesIO()
pdf_writer.write(output_buffer)
encrypted_file = output_buffer.getvalue()
# we need to change the encryption value from 4 to 5 to simulate an encryption not used by PyPDF2
encrypt_start = encrypted_file.find(b'/Encrypt')
encrypt_end = encrypted_file.find(b'>>', encrypt_start)
encrypt_version = encrypted_file[encrypt_start: encrypt_end]
encrypted_file = encrypted_file.replace(encrypt_version, encrypt_version.replace(b'4', b'5'))
# corrupt encryption: point the /Encrypt xref as a non-encrypt
# (but valid otherwise pypdf skips it)
encrypted_file, n = re.subn(
b'/Encrypt (?P<index>\\d+) (?P<gen>\\d+) R',
b'/Encrypt 1 \\g<gen> R',
encrypted_file,
)
self.assertEqual(n, 1, "should have updated the /Encrypt entry")
in_invoice_1 = self.env['account.move'].create({
'move_type': 'in_invoice',
@ -76,7 +81,7 @@ class TestIrActionsReport(AccountTestInvoicingCommon):
})
in_invoice_1.message_main_attachment_id = self.env['ir.attachment'].create({
'datas': base64.b64encode(encrypted_file),
'raw': encrypted_file,
'name': attach_name,
'mimetype': 'application/pdf',
'res_model': 'account.move',
@ -88,7 +93,7 @@ class TestIrActionsReport(AccountTestInvoicingCommon):
in_invoice_2 = in_invoice_1.copy()
in_invoice_2.message_main_attachment_id = self.env['ir.attachment'].create({
'datas': base64.b64encode(self.file),
'raw': self.file,
'name': attach_name,
'mimetype': 'application/pdf',
'res_model': 'account.move',

View file

@ -0,0 +1,117 @@
from odoo import Command
from odoo.tests import tagged, TransactionCase
@tagged('post_install', '-at_install')
class TestKpiProvider(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner_id = cls.env['res.partner'].create({'name': 'Someone'})
# Clean things for the test
cls.env['account.move'].search([
'|', ('state', '=', 'draft'),
('statement_line_id.is_reconciled', '=', False),
])._unlink_or_reverse()
def test_empty_kpi_summary(self):
# Ensure that nothing gets reported when there is nothing to report
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [])
def test_kpi_summary(self):
company_id = self.ref('base.main_company')
account_id = self.env['account.account'].search([('company_ids', '=', company_id)], limit=1)
base_move = {
'company_id': company_id,
'invoice_line_ids': [Command.create({'account_id': account_id.id, 'quantity': 15, 'price_unit': 10})],
'partner_id': self.partner_id.id,
}
self.env['account.move'].create(
[{**base_move, 'move_type': 'entry'}] * 2 +
[{**base_move, 'move_type': 'out_invoice'}] * 3 +
[{**base_move, 'move_type': 'out_refund'}] * 4 +
[{**base_move, 'move_type': 'in_invoice'}] * 5 +
[{**base_move, 'move_type': 'in_refund'}] * 6 +
[{**base_move, 'move_type': 'out_receipt'}] * 7 +
[{**base_move, 'move_type': 'in_receipt'}] * 8
)
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [
{'id': 'account_journal_type.general', 'name': 'Miscellaneous', 'type': 'integer', 'value': 2},
{'id': 'account_journal_type.sale', 'name': 'Sales', 'type': 'integer', 'value': 3 + 4 + 7},
{'id': 'account_journal_type.purchase', 'name': 'Purchase', 'type': 'integer', 'value': 5 + 6 + 8},
])
def test_kpi_summary_shouldnt_report_posted_moves(self):
company_id = self.ref('base.main_company')
account_id = self.env['account.account'].search([('company_ids', '=', company_id)], limit=1).id
move = self.env['account.move'].create({
'company_id': company_id,
'invoice_line_ids': [Command.create({'account_id': account_id, 'quantity': 15, 'price_unit': 10})],
'partner_id': self.partner_id.id,
'move_type': 'out_invoice',
})
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [
{'id': 'account_journal_type.sale', 'name': 'Sales', 'type': 'integer', 'value': 1},
])
move.action_post()
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [])
def test_kpi_summary_reports_posted_but_to_check_moves(self):
company_id = self.ref('base.main_company')
account_id = self.env['account.account'].search([('company_ids', '=', company_id)], limit=1).id
move = self.env['account.move'].create({
'company_id': company_id,
'invoice_line_ids': [Command.create({'account_id': account_id, 'quantity': 15, 'price_unit': 10})],
'partner_id': self.partner_id.id,
'move_type': 'out_invoice',
})
move.action_post()
move.checked = False
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [
{'id': 'account_journal_type.sale', 'name': 'Sales', 'type': 'integer', 'value': 1},
])
move.button_set_checked()
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [])
def test_kpi_summary_reports_unreconciled_bank_statements(self):
company_id = self.ref('base.main_company')
account_id = self.env['account.account'].search([('company_ids', '=', company_id), ('account_type', '=', 'income')], limit=1).id
move = self.env['account.move'].create({
'company_id': company_id,
'line_ids': [Command.create({'account_id': account_id, 'quantity': 15, 'price_unit': 10})],
'partner_id': self.env.user.partner_id.id,
'move_type': 'out_invoice',
})
move.action_post()
journal_id = self.env['account.journal'].create({
'name': 'Bank',
'type': 'bank',
})
bank_statement = self.env['account.bank.statement'].create({
'name': 'test_statement',
'line_ids': [Command.create({
'date': '2025-09-15',
'payment_ref': 'line_1',
'journal_id': journal_id.id,
'amount': move.amount_total,
})],
})
self.assertEqual(bank_statement.line_ids.move_id.state, 'posted')
self.assertFalse(bank_statement.line_ids.is_reconciled)
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [
{'id': 'account_journal_type.bank', 'name': 'Bank', 'type': 'integer', 'value': 1},
])
move_line = move.line_ids.filtered(lambda line: line.account_type == 'asset_receivable')
_st_liquidity_lines, st_suspense_lines, _st_other_lines = bank_statement.line_ids._seek_for_lines()
st_suspense_lines.account_id = move_line.account_id
(move_line + st_suspense_lines).reconcile()
self.assertTrue(bank_statement.line_ids.is_reconciled)
self.assertCountEqual(self.env['kpi.provider'].get_account_kpi_summary(), [])

View file

@ -4,13 +4,18 @@
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.tests import Form
from odoo.addons.mail.tests.common import MailCase
from odoo.tests import Form, users
from odoo.tests.common import tagged
@tagged('post_install', '-at_install')
class TestTracking(AccountTestInvoicingCommon, TestMailCommon):
class TestTracking(AccountTestInvoicingCommon, MailCase):
@classmethod
def default_env_context(cls):
# OVERRIDE
return {}
def test_aml_change_tracking(self):
""" tests that the field_groups is correctly set """
@ -29,11 +34,65 @@ class TestTracking(AccountTestInvoicingCommon, TestMailCommon):
new_value = account_move.invoice_line_ids.account_id
self.flush_tracking()
self.assertTracking(account_move.message_ids, [
# Isolate the tracked value for the invoice line because changing the account has recomputed the taxes.
tracking_value = account_move.message_ids.sudo().tracking_value_ids\
.filtered(lambda t: t.field_id.name == 'account_id' and t.old_value_integer == old_value.id)
self.assertTracking(tracking_value.mail_message_id, [
('account_id', 'many2one', old_value, new_value),
])
tracking_value = account_move.message_ids.sudo().tracking_value_ids
tracking_value._compute_field_groups()
self.assertEqual(len(tracking_value), 1)
self.assertTrue(tracking_value.field_id)
field = self.env[tracking_value.field_id.model]._fields[tracking_value.field_id.name]
self.assertFalse(field.groups, "There is no group on account.move.line.account_id")
self.assertEqual(tracking_value.field_groups, False, "There is no group on account.move.line.account_id")
@users('admin')
def test_invite_follower_account_moves(self):
""" Test that the mail_followers_edit wizard works on both single and multiple account.move records """
user_admin = self.env.ref('base.user_admin')
user_admin.write({
'country_id': self.env.ref('base.be').id,
'email': 'test.admin@test.example.com',
"name": "Mitchell Admin",
'notification_type': 'inbox',
'phone': '0455135790',
})
partner_admin = self.env.ref('base.partner_admin')
multiple_account_moves = [
{
'description': 'Single account.move',
'account_moves': [{'name': 'Test Single', 'partner_id': self.partner_a.id}],
'expected_partners': self.partner_a | user_admin.partner_id,
},
{
'description': 'Multiple account.moves',
'account_moves': [
{'name': 'Move 1', 'partner_id': self.partner_a.id},
{'name': 'Move 2', 'partner_id': self.partner_b.id},
],
'expected_partners': self.partner_a | user_admin.partner_id,
},
]
for move in multiple_account_moves:
with self.subTest(move['description']):
account_moves = self.env['account.move'].with_context(self._test_context).create(move['account_moves'])
mail_invite = self.env['mail.followers.edit'].with_context({
'default_res_model': 'account.move',
'default_res_ids': account_moves.ids,
}).with_user(user_admin).create({
'partner_ids': [(4, self.partner_a.id), (4, user_admin.partner_id.id)],
'notify': True,
})
with self.mock_mail_app(), self.mock_mail_gateway():
mail_invite.edit_followers()
for account_move in account_moves:
self.assertEqual(account_move.message_partner_ids, move['expected_partners'])
self.assertEqual(len(self._new_msgs), 1)
self.assertEqual(len(self._mails), 1)
self.assertNotSentEmail([partner_admin])
self.assertNotified(
self._new_msgs[0],
[{'partner': partner_admin, 'type': 'inbox', 'is_read': False}]
)

View file

@ -0,0 +1,280 @@
from unittest.mock import patch
from odoo import Command
from odoo.tests import tagged
from odoo.addons.account.models.chart_template import AccountChartTemplate
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
def _get_chart_template_mapping(self, get_all=False):
return {
'local': {
'name': 'local',
'country_id': self.env.ref("base.be").id,
'country_code': 'be',
'modules': ['account'],
'parent': None,
'installed': True,
},
'foreign': {
'name': 'foreign',
'country_id': self.env.ref("base.fr").id,
'country_code': 'fr',
'modules': ['account'],
'parent': None,
'installed': True,
},
}
def data_method_provider(chart_template_name, country_code):
country = f"base.{country_code}"
# this is used to simulated differences between xml_ids
external_id_prefix = '' if chart_template_name == 'local' else f"{chart_template_name}_"
def test_data_getter(self, template_code):
return {
'template_data': {
'code_digits': 6,
'currency_id': 'base.EUR',
},
'res.company': {
self.env.company.id: {
'bank_account_code_prefix': '1000',
'cash_account_code_prefix': '2000',
'transfer_account_code_prefix': '3000',
'income_account_id': f'{external_id_prefix}test_account_income_template',
'expense_account_id': f'{external_id_prefix}test_account_expense_template',
},
},
'account.account': {
f'{external_id_prefix}test_account_tax_recoverable_template': {
'name': f'{external_id_prefix}tax recoverable',
'code': '411000',
'account_type': 'asset_receivable',
'reconcile': True,
'non_trade': True,
},
f'{external_id_prefix}test_account_tax_receivable_template': {
'name': f'{external_id_prefix}tax receivable',
'code': '411200',
'account_type': 'asset_receivable',
'reconcile': True,
'non_trade': True,
},
f'{external_id_prefix}test_account_advance_payment_tax_template': {
'name': f'{external_id_prefix}advance tax payment',
'code': '411900',
'account_type': 'asset_current',
},
f'{external_id_prefix}test_account_tax_payable_template': {
'name': f'{external_id_prefix}tax payable',
'code': '451200',
'account_type': 'liability_payable',
'reconcile': True,
'non_trade': True,
},
f'{external_id_prefix}test_account_cash_basis_transition_account_id': {
'name': f'{external_id_prefix}cash basis transition account',
'code': '451500',
'account_type': 'liability_current',
},
f'{external_id_prefix}test_account_income_template': {
'name': f'{external_id_prefix}income',
'code': '600000',
'account_type': 'income',
},
f'{external_id_prefix}test_account_expense_template': {
'name': f'{external_id_prefix}expense',
'code': '700000',
'account_type': 'expense',
},
},
'account.journal': self._get_account_journal(template_code),
'account.tax.group': {
'tax_group_taxes': {
'name': f"{external_id_prefix}Taxes",
'sequence': 0,
'country_id': country,
'tax_payable_account_id': f'{external_id_prefix}test_account_tax_payable_template',
'tax_receivable_account_id': f'{external_id_prefix}test_account_tax_receivable_template',
'advance_tax_payment_account_id': f'{external_id_prefix}test_account_advance_payment_tax_template',
},
},
'account.tax': {
**{
xmlid: _tax_vals(name, amount, external_id_prefix)
for name, xmlid, amount in (
('Tax 1', 'test_tax_1_template', 15),
('Tax 2', 'test_tax_2_template', 0),
)
},
'test_composite_tax_template': {
'name': 'Tax Grouped',
'amount_type': 'group',
'type_tax_use': 'purchase',
'tax_group_id': 'tax_group_taxes',
'children_tax_ids': 'test_tax_1_template,test_tax_2_template',
}
},
}
return test_data_getter
def _tax_vals(name, amount, external_id_prefix, cash_basis=False, account_on_repartition=True):
return {
'name': name,
'amount': amount,
'type_tax_use': 'purchase',
'tax_group_id': 'tax_group_taxes',
'cash_basis_transition_account_id': f'{external_id_prefix}test_account_cash_basis_transition_account_id' if cash_basis else False,
'tax_exigibility': 'on_payment' if cash_basis else 'on_invoice',
'repartition_line_ids': [
Command.create({'document_type': 'invoice', 'factor_percent': 100, 'repartition_type': 'base'}),
Command.create({'document_type': 'invoice', 'factor_percent': 100, 'repartition_type': 'tax',
'account_id': f'{external_id_prefix}test_account_tax_recoverable_template' if account_on_repartition else False}),
Command.create({'document_type': 'refund', 'factor_percent': 100, 'repartition_type': 'base'}),
Command.create({'document_type': 'refund', 'factor_percent': 100, 'repartition_type': 'tax',
'account_id': f'{external_id_prefix}test_account_tax_recoverable_template' if account_on_repartition else False}),
]
}
@tagged('post_install', '-at_install')
@patch.object(AccountChartTemplate, '_get_chart_template_mapping', _get_chart_template_mapping)
class TestMultiVAT(AccountTestInvoicingCommon):
@classmethod
def _use_chart_template(cls, company, chart_template_ref=None):
test_get_data = data_method_provider("local", "be")
with patch.object(AccountChartTemplate, '_get_chart_template_data', side_effect=test_get_data, autospec=True):
cls.env['account.chart.template'].try_loading('local', company=company, install_demo=False)
@classmethod
@AccountTestInvoicingCommon.setup_country('be')
@patch.object(AccountChartTemplate, '_get_chart_template_mapping', _get_chart_template_mapping)
def setUpClass(cls):
"""
Setups a company with a custom chart template, containing a tax and a fiscal position.
We need to add xml_ids to the templates because they are loaded from their xml_ids
"""
# Avoid creating data from AccountTestInvoicingCommon setUpClass
# just use the override of the functions it provides
super(AccountTestInvoicingCommon, cls).setUpClass()
foreign_country = cls.env.ref("base.fr")
cls.foreign_vat_fpos = cls.env["account.fiscal.position"].create({
"name": "FR foreign VAT",
"auto_apply": True,
"country_id": foreign_country.id,
"foreign_vat": "FR23334175221",
})
test_get_data = data_method_provider("foreign", "fr")
with patch.object(AccountChartTemplate, '_get_chart_template_data', side_effect=test_get_data, autospec=True):
cls.foreign_vat_fpos.action_create_foreign_taxes()
def test_tax_and_tax_group_should_be_reachable_using_standard_api(self):
# Ensure local and foreign tax is reachable using the custom ref api
for xml_id in (
# tax group
'tax_group_taxes',
'foreign_tax_group_taxes',
# tax
'test_tax_1_template',
'test_tax_2_template',
'test_composite_tax_template',
'foreign_test_tax_1_template',
'foreign_test_tax_2_template',
'foreign_test_composite_tax_template'
):
with self.subTest(xml_id=xml_id):
record = self.env["account.chart.template"].ref(xml_id, raise_if_not_found=False)
self.assertTrue(record, "We should be able to retrieve the record")
def test_tax_group_data(self):
# Ensure the correct country is set on tax group
for xml_id, country_code in (('tax_group_taxes', 'BE'), ('foreign_tax_group_taxes', 'FR')):
tax_group = self.env["account.chart.template"].ref(xml_id)
with self.subTest(xml_id=xml_id, country_code=country_code):
self.assertEqual(tax_group.country_id.code, country_code)
local_tax_group = self.env["account.chart.template"].ref('tax_group_taxes')
foreign_tax_group = self.env["account.chart.template"].ref('foreign_tax_group_taxes')
for field in ('tax_payable_account_id', 'tax_receivable_account_id', 'advance_tax_payment_account_id'):
with self.subTest(field=field):
self.assertTrue(foreign_tax_group[field], "This account should have been set")
self.assertNotEqual(foreign_tax_group[field], local_tax_group[field],
"A copy of the local tax group account should have been created and set")
def test_tax_data_should_be_consistent(self):
# Ensure the correct country is set
for xml_id, country_code in (
# tax
('test_tax_1_template', 'BE'),
('test_tax_2_template', 'BE'),
('foreign_test_tax_1_template', 'FR'),
('foreign_test_tax_2_template', 'FR'),
):
model = self.env["account.chart.template"].ref(xml_id)
with self.subTest(xml_id=xml_id, country_code=country_code):
self.assertEqual(model.country_id.code, country_code)
tax = self.env["account.chart.template"].ref('foreign_test_tax_1_template')
self.assertEqual(tax.country_id.code, 'FR')
_base_line, tax_line = tax.invoice_repartition_line_ids
self.assertEqual(tax_line.account_id.code, '411001',
"The foreign tax account should be a new account with a code close to the local tax account code")
tax = self.env["account.chart.template"].ref('foreign_test_tax_2_template')
self.assertEqual(tax.country_id.code, 'FR')
_base_line, tax_line = tax.invoice_repartition_line_ids
self.assertEqual(tax_line.account_id.code, '411001',
"The previously created tax account should be reused for similar tax")
def test_children_taxes(self):
# Ensure that group-type taxes are correctly linked to their children
composite_taxes = ['test_composite_tax_template', 'foreign_test_composite_tax_template']
children_taxes = {
'test_composite_tax_template': ['test_tax_1_template', 'test_tax_2_template'],
'foreign_test_composite_tax_template': ['foreign_test_tax_1_template', 'foreign_test_tax_2_template'],
}
for xml_id in composite_taxes:
with self.subTest(xml_id=xml_id):
record = self.env["account.chart.template"].ref(xml_id, raise_if_not_found=False)
for i, child in enumerate(record.children_tax_ids):
child_tax = self.env["account.chart.template"].ref(children_taxes[xml_id][i], raise_if_not_found=False)
self.assertEqual(child.id, child_tax.id)
def test_multivat_cash_basis(self):
def wrap_data_getter_for_caba(data_getter):
def caba_data_getter(self, template_code):
rslt = data_getter(self, template_code)
rslt['account.tax']['es.caba_0_tax'] = _tax_vals("Dudu 0", 0, 'es', cash_basis=True, account_on_repartition=False)
rslt['account.tax']['es.caba_42_tax'] = _tax_vals("Dudu 42", 42, 'es', cash_basis=True)
return rslt
return caba_data_getter
foreign_country = self.env.ref("base.es")
foreign_vat_fpos = self.env["account.fiscal.position"].create({
"name": "ES foreign VAT",
"auto_apply": True,
"country_id": foreign_country.id,
"foreign_vat": "ESA12345674",
})
test_get_data = wrap_data_getter_for_caba(data_method_provider("foreign", "es"))
with patch.object(AccountChartTemplate, '_get_chart_template_data', side_effect=test_get_data, autospec=True):
foreign_vat_fpos.action_create_foreign_taxes()
created_taxes = self.env.ref('local_es.caba_0_tax') + self.env.ref('local_es.caba_42_tax')
for tax in created_taxes:
self.assertEqual(tax.tax_exigibility, 'on_payment')
self.assertEqual(tax.cash_basis_transition_account_id.code, '411005')
self.assertTrue(self.env.company.tax_exigibility, "Creating foreign cash basis taxes should enable the cash basis setting on the company.")

View file

@ -2,57 +2,24 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from odoo.tests import Form, tagged
from odoo import fields, Command
from odoo.tests.common import Form
from odoo.tools.safe_eval import datetime
@tagged('post_install', '-at_install')
class TestAccountPaymentTerms(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.other_currency = cls.setup_other_currency('EUR', rounding=0.001)
cls.pay_term_today = cls.env['account.payment.term'].create({
'name': 'Today',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 0,
}),
],
})
cls.pay_term_next_month_on_the_15 = cls.env['account.payment.term'].create({
'name': 'Next month on the 15th',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 0,
'end_month': True,
'days_after': 15,
}),
],
})
cls.pay_term_last_day_of_month = cls.env['account.payment.term'].create({
'name': 'Last Day of month',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 0,
'end_month': True
}),
],
})
cls.pay_term_first_day_next_month = cls.env['account.payment.term'].create({
'name': 'First day next month',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 0,
'end_month': True,
'days_after': 1,
'value_amount': 100,
'value': 'percent',
'nb_days': 0,
}),
],
})
@ -61,110 +28,133 @@ class TestAccountPaymentTerms(AccountTestInvoicingCommon):
'name': 'Net 30 days',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 30,
'value_amount': 100,
'value': 'percent',
'nb_days': 30,
}),
],
})
cls.pay_term_30_days_end_of_month = cls.env['account.payment.term'].create({
'name': '30 days end of month',
cls.pay_term_60_days = cls.env['account.payment.term'].create({
'name': '60 days two lines',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 30,
'end_month': True
'value_amount': 30,
'value': 'percent',
'nb_days': 15,
}),
(0, 0, {
'value_amount': 70,
'value': 'percent',
'nb_days': 45,
}),
],
})
cls.pay_term_1_month_end_of_month = cls.env['account.payment.term'].create({
'name': '1 month, end of month',
cls.pay_term_30_days = cls.env['account.payment.term'].create({
'name': '60 days two lines',
'line_ids': [
(0, 0, {
'value': 'balance',
'months': 1,
'days': 0,
'end_month': True
}),
],
})
cls.pay_term_30_days_end_of_month_the_10 = cls.env['account.payment.term'].create({
'name': '30 days end of month the 10th',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 30,
'end_month': True,
'days_after': 10,
}),
],
})
cls.pay_term_90_days_end_of_month_the_10 = cls.env['account.payment.term'].create({
'name': '90 days end of month the 10',
'line_ids': [
(0, 0, {
'value': 'balance',
'days': 90,
'end_month': True,
'days_after': 10,
}),
],
})
cls.pay_term_3_months_end_of_month_the_10 = cls.env['account.payment.term'].create({
'name': '3 months end of month the 10',
'line_ids': [
(0, 0, {
'value': 'balance',
'months': 3,
'end_month': True,
'days_after': 10,
}),
],
})
cls.pay_term_end_month_on_the_30th = cls.env['account.payment.term'].create({
'name': 'End of month, the 30th',
'line_ids': [
(0, 0, {
'value': 'balance',
'end_month': True,
'days_after': 30,
}),
],
})
cls.pay_term_1_month_15_days_end_month_45_days = cls.env['account.payment.term'].create({
'name': '1 month, 15 days, end month, 45 days',
'line_ids': [
(0, 0, {
'value': 'balance',
'months': 1,
'days': 15,
'end_month': True,
'days_after': 45,
}),
],
})
cls.pay_term_next_10_of_the_month = cls.env['account.payment.term'].create({
'name': 'Next 10th of the month',
'line_ids': [
(0, 0, {
'value': 'balance',
'months': 0,
'days': -10,
'end_month': True,
'days_after': 10,
'value_amount': 100,
'value': 'percent',
'nb_days': 15,
}),
],
})
cls.invoice = cls.init_invoice('out_refund', products=cls.product_a+cls.product_b)
cls.pay_term_a = cls.env['account.payment.term'].create({
'name': "turlututu",
'early_discount': True,
'discount_percentage': 10,
'discount_days': 1,
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 2,
}),
],
})
cls.pay_term_b = cls.env['account.payment.term'].create({
'name': "tralala",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 50,
'nb_days': 2,
}),
Command.create({
'value': 'percent',
'value_amount': 50,
'nb_days': 4,
}),
],
})
cls.pay_term_days_end_of_month_10 = cls.env['account.payment.term'].create({
'name': "basic case",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 30,
'delay_type': 'days_end_of_month_on_the',
'days_next_month': 10,
}),
],
})
cls.pay_term_days_end_of_month_31 = cls.env['account.payment.term'].create({
'name': "special case 31",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'nb_days': 30,
'delay_type': 'days_end_of_month_on_the',
'days_next_month': 31,
}),
],
})
cls.pay_term_days_end_of_month_30 = cls.env['account.payment.term'].create({
'name': "special case 30",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'delay_type': 'days_end_of_month_on_the',
'days_next_month': 30,
'nb_days': 0,
}),
],
})
cls.pay_term_days_end_of_month_29 = cls.env['account.payment.term'].create({
'name': "special case 29",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'delay_type': 'days_end_of_month_on_the',
'days_next_month': 29,
'nb_days': 0,
}),
],
})
cls.pay_term_days_end_of_month_days_next_month_0 = cls.env['account.payment.term'].create({
'name': "special case days next month 0",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 100,
'delay_type': 'days_end_of_month_on_the',
'days_next_month': 0,
'nb_days': 30,
}),
],
})
def assertPaymentTerm(self, pay_term, invoice_date, dates):
with Form(self.invoice) as move_form:
move_form.invoice_payment_term_id = pay_term
@ -172,7 +162,7 @@ class TestAccountPaymentTerms(AccountTestInvoicingCommon):
self.assertEqual(
self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']
).mapped('date_maturity'),
).sorted(key=lambda r: r.date_maturity).mapped('date_maturity'),
[fields.Date.from_string(date) for date in dates],
)
@ -180,223 +170,496 @@ class TestAccountPaymentTerms(AccountTestInvoicingCommon):
self.assertPaymentTerm(self.pay_term_today, '2019-01-01', ['2019-01-01'])
self.assertPaymentTerm(self.pay_term_today, '2019-01-15', ['2019-01-15'])
self.assertPaymentTerm(self.pay_term_today, '2019-01-31', ['2019-01-31'])
self.assertPaymentTerm(self.pay_term_next_month_on_the_15, '2019-01-01', ['2019-02-15'])
self.assertPaymentTerm(self.pay_term_next_month_on_the_15, '2019-01-15', ['2019-02-15'])
self.assertPaymentTerm(self.pay_term_next_month_on_the_15, '2019-01-31', ['2019-02-15'])
self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-01', ['2019-01-31'])
self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-15', ['2019-01-31'])
self.assertPaymentTerm(self.pay_term_last_day_of_month, '2019-01-31', ['2019-01-31'])
self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-01', ['2019-02-01'])
self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-15', ['2019-02-01'])
self.assertPaymentTerm(self.pay_term_first_day_next_month, '2019-01-31', ['2019-02-01'])
self.assertPaymentTerm(self.pay_term_net_30_days, '2022-01-01', ['2022-01-31'])
self.assertPaymentTerm(self.pay_term_net_30_days, '2022-01-15', ['2022-02-14'])
self.assertPaymentTerm(self.pay_term_net_30_days, '2022-01-31', ['2022-03-02'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month, '2022-01-01', ['2022-01-31'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month, '2022-01-15', ['2022-02-28'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month, '2022-01-31', ['2022-03-31'])
self.assertPaymentTerm(self.pay_term_1_month_end_of_month, '2022-01-01', ['2022-02-28'])
self.assertPaymentTerm(self.pay_term_1_month_end_of_month, '2022-01-15', ['2022-02-28'])
self.assertPaymentTerm(self.pay_term_1_month_end_of_month, '2022-01-31', ['2022-02-28'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month_the_10, '2022-01-01', ['2022-02-10'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month_the_10, '2022-01-15', ['2022-03-10'])
self.assertPaymentTerm(self.pay_term_30_days_end_of_month_the_10, '2022-01-31', ['2022-04-10'])
self.assertPaymentTerm(self.pay_term_90_days_end_of_month_the_10, '2022-01-01', ['2022-05-10'])
self.assertPaymentTerm(self.pay_term_90_days_end_of_month_the_10, '2022-01-15', ['2022-05-10'])
self.assertPaymentTerm(self.pay_term_90_days_end_of_month_the_10, '2022-01-31', ['2022-06-10'])
self.assertPaymentTerm(self.pay_term_3_months_end_of_month_the_10, '2022-01-01', ['2022-05-10'])
self.assertPaymentTerm(self.pay_term_3_months_end_of_month_the_10, '2022-01-15', ['2022-05-10'])
self.assertPaymentTerm(self.pay_term_3_months_end_of_month_the_10, '2022-01-31', ['2022-05-10'])
self.assertPaymentTerm(self.pay_term_1_month_15_days_end_month_45_days, '2022-01-01', ['2022-04-14'])
self.assertPaymentTerm(self.pay_term_1_month_15_days_end_month_45_days, '2022-01-15', ['2022-05-15'])
self.assertPaymentTerm(self.pay_term_1_month_15_days_end_month_45_days, '2022-01-31', ['2022-05-15'])
self.assertPaymentTerm(self.pay_term_next_10_of_the_month, '2022-01-01', ['2022-01-10'])
self.assertPaymentTerm(self.pay_term_next_10_of_the_month, '2022-01-09', ['2022-01-10'])
self.assertPaymentTerm(self.pay_term_next_10_of_the_month, '2022-01-10', ['2022-01-10'])
self.assertPaymentTerm(self.pay_term_next_10_of_the_month, '2022-01-15', ['2022-02-10'])
self.assertPaymentTerm(self.pay_term_next_10_of_the_month, '2022-01-31', ['2022-02-10'])
def test_payment_term_compute_method(self):
def assert_payment_term_values(expected_values_list):
res = pay_term._compute_terms(
self.assertPaymentTerm(self.pay_term_60_days, '2022-01-01', ['2022-01-16', '2022-02-15'])
self.assertPaymentTerm(self.pay_term_60_days, '2022-01-15', ['2022-01-30', '2022-03-01'])
self.assertPaymentTerm(self.pay_term_60_days, '2022-01-31', ['2022-02-15', '2022-03-17'])
def test_wrong_payment_term(self):
with self.assertRaises(ValidationError):
self.env['account.payment.term'].create({
'name': 'Wrong Payment Term',
'line_ids': [
(0, 0, {
'value': 'percent',
'value_amount': 50,
}),
],
})
def test_payment_term_compute_method_with_cash_discount(self):
self.pay_term_a.early_pay_discount_computation = 'included'
computed_term_a = self.pay_term_a._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
150, 150, 1, 1000, 1000,
150.0, 150.0, 1.0, 1000.0, 1000.0,
)
self.assertEqual(len(res), len(expected_values_list))
for values, (company_amount, discount_balance) in zip(res, expected_values_list):
self.assertDictEqual(
{
'company_amount': values['company_amount'],
'discount_balance': values['discount_balance'],
},
{
self.assertDictEqual(
{
'total_amount': computed_term_a.get("total_amount"),
'discount_balance': computed_term_a.get("discount_balance"),
'line_ids': computed_term_a.get("line_ids"),
},
#What should be obtained
{
'total_amount': 1150.0,
'discount_balance': 1035.0,
'line_ids': [{
'date': datetime.date(2016, 1, 3),
'company_amount': 1150.0,
'foreign_amount': 1150.0,
}],
},
)
'company_amount': company_amount,
'discount_balance': discount_balance,
},
def test_payment_term_compute_method_with_cash_discount_and_cash_rounding(self):
foreign_currency = self.other_currency
rate = self.env['res.currency']._get_conversion_rate(foreign_currency, self.env.company.currency_id, self.env.company, '2017-01-01')
self.assertEqual(rate, 0.5)
self.pay_term_a.early_pay_discount_computation = 'included'
computed_term_a = self.pay_term_a._compute_terms(
fields.Date.from_string('2016-01-01'), foreign_currency, self.env.company,
75, 150, 1, 359.18, 718.35, cash_rounding=self.cash_rounding_a,
)
self.assertDictEqual(
{
'total_amount': computed_term_a.get("total_amount"),
'discount_balance': computed_term_a.get("discount_balance"),
'discount_amount_currency': computed_term_a.get("discount_amount_currency"),
'line_ids': computed_term_a.get("line_ids"),
},
# What should be obtained
{
'total_amount': 434.18,
'discount_balance': 390.78,
'discount_amount_currency': 781.55, # w/o cash rounding: 868.35 * 0.9 = 781.515
'line_ids': [{
'date': datetime.date(2016, 1, 3),
'company_amount': 434.18,
'foreign_amount': 868.35,
}],
},
)
def test_payment_term_compute_method_without_cash_discount(self):
computed_term_b = self.pay_term_b._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
150.0, 150.0, 1.0, 1000.0, 1000.0,
)
self.assertDictEqual(
{
'total_amount': computed_term_b.get("total_amount"),
'discount_balance': computed_term_b.get("discount_balance"),
'line_ids': computed_term_b.get("line_ids"),
},
# What should be obtained
{
'total_amount': 1150.0,
'discount_balance': 0,
'line_ids': [{
'date': datetime.date(2016, 1, 3),
'company_amount': 575.0,
'foreign_amount': 575.0,
}, {
'date': datetime.date(2016, 1, 5),
'company_amount': 575.0,
'foreign_amount': 575.0,
}],
},
)
def test_payment_term_compute_method_without_cash_discount_with_cash_rounding(self):
foreign_currency = self.other_currency
rate = self.env['res.currency']._get_conversion_rate(foreign_currency, self.env.company.currency_id, self.env.company, '2017-01-01')
self.assertEqual(rate, 0.5)
self.pay_term_a.early_pay_discount_computation = 'included'
computed_term_b = self.pay_term_b._compute_terms(
fields.Date.from_string('2016-01-01'), foreign_currency, self.env.company,
75, 150, 1, 359.18, 718.35, cash_rounding=self.cash_rounding_a,
)
self.assertDictEqual(
{
'total_amount': computed_term_b.get("total_amount"),
'discount_balance': computed_term_b.get("discount_balance"),
'discount_amount_currency': computed_term_b.get("discount_amount_currency"),
'line_ids': computed_term_b.get("line_ids"),
},
# What should be obtained
{
'total_amount': 434.18,
'discount_balance': 0,
'discount_amount_currency': None,
'line_ids': [{
'date': datetime.date(2016, 1, 3),
'company_amount': 217.1,
'foreign_amount': 434.2,
}, {
'date': datetime.date(2016, 1, 5),
'company_amount': 217.08,
'foreign_amount': 434.15000000000003,
}],
},
)
# Cash rounding should not affect the totals
self.assertAlmostEqual(434.18, sum(line['company_amount'] for line in computed_term_b['line_ids']))
self.assertAlmostEqual(868.35, sum(line['foreign_amount'] for line in computed_term_b['line_ids']))
def test_payment_term_compute_method_early_excluded(self):
self.pay_term_a.early_pay_discount_computation = 'excluded'
computed_term_a = self.pay_term_a._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
150.0, 150.0, 1.0, 1000.0, 1000.0,
)
self.assertDictEqual(
{
'total_amount': computed_term_a.get("total_amount"),
'discount_balance': computed_term_a.get("discount_balance"),
'line_ids': computed_term_a.get("line_ids"),
},
# What should be obtained
{
'total_amount': 1150.0,
'discount_balance': 1050.0,
'line_ids': [{
'date': datetime.date(2016, 1, 3),
'company_amount': 1150.0,
'foreign_amount': 1150.0,
}],
},
)
def test_payment_term_residual_amount_on_last_line_with_fixed_amount_multi_currency(self):
pay_term = self.env['account.payment.term'].create({
'name': "test_payment_term_residual_amount_on_last_line",
'line_ids': [
Command.create({
'value_amount': 50,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 50,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 0.02,
'value': 'fixed',
'nb_days': 0,
}),
],
})
computed_term = pay_term._compute_terms(
fields.Date.from_string('2016-01-01'), self.other_currency, self.env.company,
0.0, 0.0, 1.0, 0.04, 0.09,
)
self.assertEqual(
[
(
self.other_currency.round(l['foreign_amount']),
self.company_data['currency'].round(l['company_amount']),
)
for l in computed_term['line_ids']
],
[(0.045, 0.02), (0.045, 0.02), (0.0, 0.0)],
)
def test_payment_term_residual_amount_on_last_line(self):
pay_term = self.env['account.payment.term'].create({
'name': "turlututu",
'name': "test_payment_term_residual_amount_on_last_line",
'line_ids': [
Command.create({
'value_amount': 50,
'value': 'percent',
'value_amount': 10,
'days': 2,
'discount_percentage': 10,
'discount_days': 1,
'nb_days': 0,
}),
Command.create({
'value_amount': 50,
'value': 'percent',
'value_amount': 20,
'days': 4,
'discount_percentage': 20,
'discount_days': 3,
}),
Command.create({
'value': 'percent',
'value_amount': 20,
'days': 6,
}),
Command.create({
'value': 'balance',
'days': 8,
'discount_percentage': 20,
'discount_days': 7,
'nb_days': 0,
}),
],
})
self.env.company.early_pay_discount_computation = 'included'
assert_payment_term_values([
(115.0, 103.5),
(230.0, 184.0),
(230.0, 0.0),
(575.0, 460.0),
])
computed_term = pay_term._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
0.0, 0.0, 1.0, 0.03, 0.03,
)
self.assertEqual(
[self.env.company.currency_id.round(l['foreign_amount']) for l in computed_term['line_ids']],
[0.02, 0.01],
)
self.env.company.early_pay_discount_computation = 'excluded'
assert_payment_term_values([
(115.0, 105.0),
(230.0, 190.0),
(230.0, 0.0),
(575.0, 475.0),
])
def test_payment_term_last_balance_line_with_fixed(self):
pay_term = self.env['account.payment.term'].create({
'name': 'test_payment_term_last_balance_line_with_fixed',
'line_ids': [
Command.create({
'value_amount': 70,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 200,
'value': 'fixed',
'nb_days': 0,
}),
Command.create({
'value_amount': 30,
'value': 'percent',
'nb_days': 0,
}),
]
})
def test_payment_term_compute_method_cash_rounding(self):
"""Test that the payment terms are computed correctly in case we apply cash rounding.
We check the amounts in document and company currency.
We check that the cash rounding does not change the totals in document or company curreny.
computed_term = pay_term._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
0.0, 0.0, 1.0, 1000.0, 1000.0,
)
self.assertEqual(
[self.env.company.currency_id.round(l['foreign_amount']) for l in computed_term['line_ids']],
[700.0, 200.0, 100.0],
)
def test_payment_term_last_balance_line_with_fixed_negative(self):
pay_term = self.env['account.payment.term'].create({
'name': 'test_payment_term_last_balance_line_with_fixed_negative',
'line_ids': [
Command.create({
'value_amount': 70,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 500,
'value': 'fixed',
'nb_days': 0,
}),
Command.create({
'value_amount': 30,
'value': 'percent',
'nb_days': 0,
}),
]
})
computed_term = pay_term._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
0.0, 0.0, 1.0, 1000.0, 1000.0,
)
self.assertEqual(
[self.env.company.currency_id.round(l['foreign_amount']) for l in computed_term['line_ids']],
[700.0, 500.0, -200.0],
)
def test_payment_term_last_balance_line_with_fixed_negative_fixed(self):
pay_term = self.env['account.payment.term'].create({
'name': 'test_payment_term_last_balance_line_with_fixed_negative_fixed',
'line_ids': [
Command.create({
'value_amount': 70,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 500,
'value': 'fixed',
'nb_days': 0,
}),
Command.create({
'value_amount': 30,
'value': 'percent',
'nb_days': 0,
}),
Command.create({
'value_amount': 200,
'value': 'fixed',
'nb_days': 0,
}),
]
})
computed_term = pay_term._compute_terms(
fields.Date.from_string('2016-01-01'), self.env.company.currency_id, self.env.company,
0.0, 0.0, 1.0, 1000.0, 1000.0,
)
self.assertEqual(
[self.env.company.currency_id.round(l['foreign_amount']) for l in computed_term['line_ids']],
[700.0, 500.0, 300.0, -500.0],
)
def test_payment_term_percent_round_calculation(self):
"""
def assert_payment_term_values(expected_values_list):
foreign_currency = self.currency_data['currency']
rate = self.env['res.currency']._get_conversion_rate(foreign_currency, self.env.company.currency_id, self.env.company, '2017-01-01')
self.assertEqual(rate, 0.5)
res = pay_term._compute_terms(
fields.Date.from_string('2017-01-01'), foreign_currency, self.env.company,
75, 150, 1, 359.18, 718.35, cash_rounding=self.cash_rounding_a
)
self.assertEqual(len(res), len(expected_values_list))
keys = ['company_amount', 'discount_balance', 'foreign_amount', 'discount_amount_currency']
for index, (values, expected_values) in enumerate(zip(res, expected_values_list)):
for key in keys:
with self.subTest(index=index, key=key):
self.assertAlmostEqual(values[key], expected_values[key])
total_company_amount = sum(value['company_amount'] for value in res)
total_foreign_amount = sum(value['foreign_amount'] for value in res)
self.assertAlmostEqual(total_company_amount, 434.18)
self.assertAlmostEqual(total_foreign_amount, 868.35)
pay_term = self.env['account.payment.term'].create({
'name': "turlututu",
the sum function might not sum the floating numbers properly
if there are a lot of lines with floating numbers
so this test verifies the round function changes
"""
self.env['account.payment.term'].create({
'name': "test_payment_term_percent_round_calculation",
'line_ids': [
Command.create({
'value': 'percent',
'value_amount': 10,
'days': 2,
'discount_percentage': 10,
'discount_days': 1,
}),
Command.create({
'value': 'percent',
'value_amount': 20,
'days': 4,
'discount_percentage': 20,
'discount_days': 3,
}),
Command.create({
'value': 'percent',
'value_amount': 20,
'days': 6,
}),
Command.create({
'value': 'balance',
'days': 8,
'discount_percentage': 20,
'discount_days': 7,
}),
Command.create({'value_amount': 50, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 1.66, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 16.8, 'value': 'percent', 'nb_days': 0, }),
],
})
with self.subTest(test='included'):
self.env.company.early_pay_discount_computation = 'included'
assert_payment_term_values([
{
'company_amount': 43.43,
'discount_balance': 39.11,
'foreign_amount': 86.85,
'discount_amount_currency': 78.20,
},
{
'company_amount': 86.86,
'discount_balance': 69.51,
'foreign_amount': 173.70,
'discount_amount_currency': 139.00,
},
{
'company_amount': 86.86,
'discount_balance': 0,
'foreign_amount': 173.70,
'discount_amount_currency': 0.00,
},
{
'company_amount': 217.03,
'discount_balance': 173.63,
'foreign_amount': 434.10,
'discount_amount_currency': 347.30,
},
])
def test_payment_term_days_end_of_month_on_the(self):
"""
This test will check that payment terms with a delay_type 'days_end_of_month_on_the' works as expected.
It will check if the date of the date maturity is correctly calculated depending on the invoice date and payment
term selected.
"""
with Form(self.invoice) as basic_case:
basic_case.invoice_payment_term_id = self.pay_term_days_end_of_month_10
basic_case.invoice_date = '2023-12-12'
with self.subTest(test='excluded'):
self.env.company.early_pay_discount_computation = 'excluded'
assert_payment_term_values([
{
'company_amount': 43.43,
'discount_balance': 39.86,
'foreign_amount': 86.85,
'discount_amount_currency': 79.70,
},
{
'company_amount': 86.86,
'discount_balance': 72.51,
'foreign_amount': 173.70,
'discount_amount_currency': 145.00,
},
{
'company_amount': 86.86,
'discount_balance': 0,
'foreign_amount': 173.70,
'discount_amount_currency': 0.00,
},
{
'company_amount': 217.03,
'discount_balance': 181.13,
'foreign_amount': 434.10,
'discount_amount_currency': 362.30,
},
])
expected_date_basic_case = self.invoice.line_ids.filtered(lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity'),
self.assertEqual(expected_date_basic_case[0], [fields.Date.from_string('2024-02-10')])
with Form(self.invoice) as special_case:
special_case.invoice_payment_term_id = self.pay_term_days_end_of_month_31
special_case.invoice_date = '2023-12-12'
expected_date_special_case = self.invoice.line_ids.filtered(lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity'),
self.assertEqual(expected_date_special_case[0], [fields.Date.from_string('2024-02-29')])
def test_payment_term_labels(self):
# create a payment term with 40% now, 30% in 30 days and 30% in 60 days
multiple_installment_term = self.env['account.payment.term'].create({
'name': "test_payment_term_labels",
'line_ids': [
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0, }),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30, }),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60, }),
],
})
# create immediate payment term
immediate_term = self.env['account.payment.term'].create({
'name': 'Immediate',
'line_ids': [
Command.create({'value_amount': 100, 'value': 'percent', 'nb_days': 0, }),
],
})
# create an invoice with immediate payment term
invoice = self.init_invoice('out_invoice', products=self.product_a)
invoice.invoice_payment_term_id = immediate_term
# check the payment term labels
invoice_terms = invoice.line_ids.filtered(lambda l: l.display_type == 'payment_term')
self.assertEqual(invoice_terms[0].name, False)
# change the payment term to the multiple installment term
invoice.invoice_payment_term_id = multiple_installment_term
invoice_terms = invoice.line_ids.filtered(lambda l: l.display_type == 'payment_term').sorted('date_maturity')
self.assertEqual(invoice_terms[0].name, 'installment #1')
self.assertEqual(invoice_terms[0].debit, invoice.amount_total * 0.4)
self.assertEqual(invoice_terms[1].name, 'installment #2')
self.assertEqual(invoice_terms[1].debit, invoice.amount_total * 0.3)
self.assertEqual(invoice_terms[2].name, 'installment #3')
self.assertEqual(invoice_terms[2].debit, invoice.amount_total * 0.3)
def test_payment_term_days_end_of_month_nb_days_0(self):
"""
This test will check that payment terms with a delay_type 'days_end_of_month_on_the'
in combination with nb_days works as expected
Invoice date = 2024-05-23
# case 1
'nb_days' = 0
`days_next_month` = 29
-> 2024-05-23 + 0 days = 2024-05-23
=> `date_maturity` -> 2024-06-29
# case 2
'nb_days' = 0
`days_next_month` = 31
-> 2024-05-23 + 0 days = 2024-05-23
=> `date_maturity` -> 2024-06-30
"""
self.pay_term_days_end_of_month_29.line_ids.nb_days = 0
self.pay_term_days_end_of_month_31.line_ids.nb_days = 0
with Form(self.invoice) as case_1:
case_1.invoice_payment_term_id = self.pay_term_days_end_of_month_29
case_1.invoice_date = '2024-05-23'
expected_date_case_1 = self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity')
self.assertEqual(expected_date_case_1, [fields.Date.from_string('2024-06-29')])
with Form(self.invoice) as case_2:
case_2.invoice_payment_term_id = self.pay_term_days_end_of_month_31
case_2.invoice_date = '2024-05-23'
expected_date_case_2 = self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity')
self.assertEqual(expected_date_case_2, [fields.Date.from_string('2024-06-30')])
def test_payment_term_days_end_of_month_nb_days_15(self):
"""
This test will check that payment terms with a delay_type 'days_end_of_month_on_the'
in combination with nb_days works as expected
Invoice date = 2024-05-23
# case 1
'nb_days' = 15
`days_next_month` = 30
-> 2024-05-23 + 15 days = 2024-06-07
=> `date_maturity` -> 2024-07-30
# case 2
'nb_days' = 15
`days_next_month` = 31
-> 2024-05-23 + 15 days = 2024-06-07
=> `date_maturity` -> 2024-07-31
"""
self.pay_term_days_end_of_month_30.line_ids.nb_days = 15
self.pay_term_days_end_of_month_31.line_ids.nb_days = 15
with Form(self.invoice) as case_1:
case_1.invoice_payment_term_id = self.pay_term_days_end_of_month_30
case_1.invoice_date = '2024-05-24'
expected_date_case_1 = self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity')
self.assertEqual(expected_date_case_1, [fields.Date.from_string('2024-07-30')])
with Form(self.invoice) as case_2:
case_2.invoice_payment_term_id = self.pay_term_days_end_of_month_31
case_2.invoice_date = '2024-05-23'
expected_date_case_2 = self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity')
self.assertEqual(expected_date_case_2, [fields.Date.from_string('2024-07-31')])
def test_payment_term_days_end_of_month_days_next_month_0(self):
with Form(self.invoice) as case_1:
case_1.invoice_payment_term_id = self.pay_term_days_end_of_month_days_next_month_0
case_1.invoice_date = '2024-04-22'
expected_date_case_1 = self.invoice.line_ids.filtered(
lambda l: l.account_id == self.company_data['default_account_receivable']).mapped('date_maturity')
self.assertEqual(expected_date_case_1, [fields.Date.from_string('2024-05-31')])
def test_payment_term_multi_company(self):
"""
@ -404,7 +667,8 @@ class TestAccountPaymentTerms(AccountTestInvoicingCommon):
OdooBot has `res.company(1)` set as the default company. The test checks that the payment term correctly reflects
the company associated with the move, independent of the user's default company.
"""
user_company, other_company = self.company_data_2.get('company'), self.company_data.get('company')
user_company = self.env['res.company'].create({'name': 'user_company'})
other_company = self.company_data.get('company')
self.env.user.write({
'company_ids': [user_company.id, other_company.id],
'company_id': user_company.id,

View file

@ -1,20 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
from odoo.tests.common import tagged
import json
from odoo import http
from odoo.tools import mute_logger
from odoo.tools import file_open, mute_logger
@tagged('post_install', '-at_install')
class TestPortalAttachment(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.out_invoice = cls.env['account.move'].with_context(tracking_disable=True).create({
'move_type': 'out_invoice',
@ -38,33 +38,36 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon):
self.authenticate(None, None)
# Test public user can't create attachment without token of document
res = self.url_open(
url=f'{self.invoice_base_url}/portal/attachment/add',
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.Request.csrf_token(self),
},
files=[('file', ('test.txt', b'test', 'plain/text'))],
)
self.assertEqual(res.status_code, 400)
self.assertIn("you do not have the rights", res.text)
with file_open("addons/web/__init__.py") as file:
res = self.url_open(
url=f"{self.invoice_base_url}/mail/attachment/upload",
data={
"csrf_token": http.Request.csrf_token(self),
"thread_id": self.out_invoice.id,
"thread_model": self.out_invoice._name,
},
files={"ufile": file},
)
self.assertEqual(res.status_code, 404)
self.assertIn("The requested URL was not found on the server.", res.text)
# Test public user can create attachment with token
res = self.url_open(
url=f'{self.invoice_base_url}/portal/attachment/add',
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.Request.csrf_token(self),
'access_token': self.out_invoice._portal_ensure_token(),
},
files=[('file', ('test.txt', b'test', 'plain/text'))],
)
with file_open("addons/web/__init__.py") as file:
res = self.url_open(
url=f"{self.invoice_base_url}/mail/attachment/upload",
data={
"csrf_token": http.Request.csrf_token(self),
"thread_id": self.out_invoice.id,
"thread_model": self.out_invoice._name,
"token": self.out_invoice._portal_ensure_token(),
},
files={"ufile": file},
)
self.assertEqual(res.status_code, 200)
create_res = json.loads(res.content.decode('utf-8'))
data = json.loads(res.content.decode('utf-8'))['data']
create_res = next(
filter(lambda a: a["id"] == data["attachment_id"], data["store_data"]["ir.attachment"])
)
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
# Test created attachment is private
@ -72,36 +75,48 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon):
self.assertEqual(res_binary.status_code, 404)
# Test created access_token is working
res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
res_binary = self.url_open(
"/web/content/%d?access_token=%s"
% (create_res["id"], create_res["raw_access_token"])
)
self.assertEqual(res_binary.status_code, 200)
# Test mimetype is neutered as non-admin
res = self.url_open(
url=f'{self.invoice_base_url}/portal/attachment/add',
url=f"{self.invoice_base_url}/mail/attachment/upload",
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.Request.csrf_token(self),
'access_token': self.out_invoice._portal_ensure_token(),
"csrf_token": http.Request.csrf_token(self),
"is_pending": True,
"thread_id": self.out_invoice.id,
"thread_model": self.out_invoice._name,
"token": self.out_invoice._portal_ensure_token(),
},
files=[('file', ('test.svg', b'<svg></svg>', 'image/svg+xml'))],
files={"ufile": ("test.svg", b'<svg></svg>', "image/svg+xml")},
)
self.assertEqual(res.status_code, 200)
create_res = json.loads(res.content.decode('utf-8'))
data = json.loads(res.content.decode('utf-8'))["data"]
create_res = next(
filter(lambda a: a["id"] == data["attachment_id"], data["store_data"]["ir.attachment"])
)
self.assertEqual(create_res['mimetype'], 'text/plain')
res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
res_binary = self.url_open(
"/web/content/%d?access_token=%s"
% (create_res["id"], create_res["raw_access_token"])
)
self.assertEqual(res_binary.headers['Content-Type'], 'text/plain; charset=utf-8')
self.assertEqual(res_binary.content, b'<svg></svg>')
res_image = self.url_open('/web/image/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
res_image = self.url_open(
"/web/image/%d?access_token=%s"
% (create_res["id"], create_res["raw_access_token"])
)
self.assertEqual(res_image.headers['Content-Type'], 'application/octet-stream')
self.assertEqual(res_image.content, b'<svg></svg>')
# Test attachment can't be removed without valid token
res = self.opener.post(
url=f'{self.invoice_base_url}/portal/attachment/remove',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/attachment/delete',
json={
'params': {
'attachment_id': create_res['id'],
@ -111,177 +126,198 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon):
)
self.assertEqual(res.status_code, 200)
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
self.assertIn("you do not have the rights", res.text)
self.assertIn("The requested URL was not found on the server.", res.text)
# Test attachment can be removed with token if "pending" state
res = self.opener.post(
url=f'{self.invoice_base_url}/portal/attachment/remove',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/attachment/delete',
json={
'params': {
'attachment_id': create_res['id'],
'access_token': create_res['access_token'],
"access_token": create_res["ownership_token"],
},
},
)
self.assertEqual(res.status_code, 200)
remove_res = json.loads(res.content.decode('utf-8'))['result']
self.assertFalse(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
self.assertTrue(remove_res is True)
# Test attachment can't be removed if not "pending" state
# Test attachment can be removed with token if not "pending" state
attachment = self.env['ir.attachment'].create({
'name': 'an attachment',
'access_token': self.env['ir.attachment']._generate_access_token(),
})
res = self.opener.post(
url=f'{self.invoice_base_url}/portal/attachment/remove',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/attachment/delete',
json={
'params': {
'attachment_id': attachment.id,
'access_token': attachment.access_token,
"access_token": attachment._get_ownership_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', attachment.id)]))
self.assertIn("not in a pending state", res.text)
self.assertFalse(self.env['ir.attachment'].sudo().search([('id', '=', attachment.id)]))
# Test attachment can't be removed if attached to a message
attachment.write({
'res_model': 'mail.compose.message',
'res_id': 0,
# Test attachment can be removed if attached to a message
attachment = self.env["ir.attachment"].create({
"name": "an attachment",
"res_model": "mail.compose.message",
"res_id": 0,
})
attachment.flush_recordset()
message = self.env['mail.message'].create({
'attachment_ids': [(6, 0, attachment.ids)],
})
res = self.opener.post(
url=f'{self.invoice_base_url}/portal/attachment/remove',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/attachment/delete',
json={
'params': {
'attachment_id': attachment.id,
'access_token': attachment.access_token,
"access_token": attachment._get_ownership_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(attachment.exists())
self.assertIn("it is linked to a message", res.text)
self.assertFalse(attachment.exists())
message.sudo().unlink()
# Test attachment can't be associated if no attachment token.
res = self.opener.post(
url=f'{self.invoice_base_url}/mail/chatter_post',
attachment = self.env['ir.attachment'].create({
'name': 'an attachment',
'res_model': 'mail.compose.message',
'res_id': 0,
})
res = self.url_open(
url=f'{self.invoice_base_url}/mail/message/post',
json={
'params': {
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'message': "test message 1",
'attachment_ids': [attachment.id],
'attachment_tokens': ['false'],
'csrf_token': http.Request.csrf_token(self),
'thread_model': self.out_invoice._name,
'thread_id': self.out_invoice.id,
'post_data': {
'body': "test message 1",
'attachment_ids': [attachment.id],
"attachment_tokens": ["false"],
},
"token": self.out_invoice._portal_ensure_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.assertIn("The attachment %s does not exist or you do not have the rights to access it." % attachment.id, res.text)
self.assertIn(
"One or more attachments do not exist, or you do not have the rights to access them.",
res.text,
)
# Test attachment can't be associated if no main document token
res = self.opener.post(
url=f'{self.invoice_base_url}/mail/chatter_post',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/message/post',
json={
'params': {
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'message': "test message 1",
'attachment_ids': [attachment.id],
'attachment_tokens': [attachment.access_token],
'csrf_token': http.Request.csrf_token(self),
'thread_model': self.out_invoice._name,
'thread_id': self.out_invoice.id,
"post_data": {
"body": "test message 1",
'attachment_ids': [attachment.id],
"attachment_tokens": [attachment._get_ownership_token()],
},
},
},
)
self.assertEqual(res.status_code, 200)
self.assertIn("You are not allowed to access 'Journal Entry' (account.move) records.", res.text)
self.assertIn("The requested URL was not found on the server.", res.text)
# Test attachment can't be associated if not "pending" state
self.assertFalse(self.out_invoice.message_ids)
# not messages which are sent by `_post_add_create` in the previous steps
self.assertFalse(
self.out_invoice.message_ids.filtered(lambda m: m.author_id == self.partner_a))
attachment.write({'res_model': 'model'})
res = self.opener.post(
url=f'{self.invoice_base_url}/mail/chatter_post',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/message/post',
json={
'params': {
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'message': "test message 1",
'attachment_ids': [attachment.id],
'attachment_tokens': [attachment.access_token],
'csrf_token': http.Request.csrf_token(self),
'thread_model': self.out_invoice._name,
'thread_id': self.out_invoice.id,
"post_data": {
"body": "test message 1",
"attachment_ids": [attachment.id],
"attachment_tokens": [attachment._get_ownership_token()],
},
'token': self.out_invoice._portal_ensure_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.out_invoice.invalidate_recordset(['message_ids'])
self.assertEqual(len(self.out_invoice.message_ids), 1)
self.assertEqual(self.out_invoice.message_ids.body, "<p>test message 1</p>")
self.assertFalse(self.out_invoice.message_ids.attachment_ids)
# not messages which are sent by `_post_add_create` in the previous steps
message = self.out_invoice.message_ids.filtered(lambda m: m.author_id == self.partner_a)
self.assertEqual(len(message), 1)
self.assertEqual(message.body, "<p>test message 1</p>")
self.assertFalse(message.attachment_ids)
# Test attachment can't be associated if not correct user
attachment.write({'res_model': 'mail.compose.message'})
res = self.opener.post(
url=f'{self.invoice_base_url}/mail/chatter_post',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/message/post',
json={
'params': {
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'message': "test message 2",
'attachment_ids': [attachment.id],
'attachment_tokens': [attachment.access_token],
'csrf_token': http.Request.csrf_token(self),
'thread_model': self.out_invoice._name,
'thread_id': self.out_invoice.id,
"post_data": {
"body": "test message 2",
"attachment_ids": [attachment.id],
"attachment_tokens": [attachment._get_ownership_token()],
},
'token': self.out_invoice._portal_ensure_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.out_invoice.invalidate_recordset(['message_ids'])
self.assertEqual(len(self.out_invoice.message_ids), 2)
self.assertEqual(self.out_invoice.message_ids[0].author_id, self.partner_a)
self.assertEqual(self.out_invoice.message_ids[0].body, "<p>test message 2</p>")
self.assertEqual(self.out_invoice.message_ids[0].email_from, self.partner_a.email_formatted)
self.assertFalse(self.out_invoice.message_ids.attachment_ids)
# not messages which are sent by `_post_add_create` in the previous steps
messages = self.out_invoice.message_ids.filtered(lambda m: m.author_id == self.partner_a)
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0].author_id, self.partner_a)
self.assertEqual(messages[0].body, "<p>test message 2</p>")
self.assertEqual(messages[0].email_from, self.partner_a.email_formatted)
self.assertFalse(messages.attachment_ids)
# Test attachment can be associated if all good (complete flow)
res = self.url_open(
url=f'{self.invoice_base_url}/portal/attachment/add',
url=f"{self.invoice_base_url}/mail/attachment/upload",
data={
'name': "final attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.Request.csrf_token(self),
'access_token': self.out_invoice._portal_ensure_token(),
"csrf_token": http.Request.csrf_token(self),
"is_pending": True,
"thread_id": self.out_invoice.id,
"thread_model": self.out_invoice._name,
"token": self.out_invoice._portal_ensure_token(),
},
files=[('file', ('test.txt', b'test', 'plain/text'))],
files={"ufile": ("final attachment", b'test', "plain/text")},
)
self.assertEqual(res.status_code, 200)
create_res = json.loads(res.content.decode('utf-8'))
data = json.loads(res.content.decode('utf-8'))['data']
create_res = next(
filter(lambda a: a["id"] == data["attachment_id"], data["store_data"]["ir.attachment"])
)
self.assertEqual(create_res['name'], "final attachment")
res = self.opener.post(
url=f'{self.invoice_base_url}/mail/chatter_post',
res = self.url_open(
url=f'{self.invoice_base_url}/mail/message/post',
json={
'params': {
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'message': "test message 3",
'attachment_ids': [create_res['id']],
'attachment_tokens': [create_res['access_token']],
'csrf_token': http.Request.csrf_token(self),
'thread_model': self.out_invoice._name,
'thread_id': self.out_invoice.id,
"post_data": {
"body": "test message 3",
"attachment_ids": [create_res['id']],
"attachment_tokens": [create_res["ownership_token"]],
},
'token': self.out_invoice._portal_ensure_token(),
},
},
)
self.assertEqual(res.status_code, 200)
self.out_invoice.invalidate_recordset(['message_ids'])
self.assertEqual(len(self.out_invoice.message_ids), 3)
self.assertEqual(self.out_invoice.message_ids[0].body, "<p>test message 3</p>")
self.assertEqual(len(self.out_invoice.message_ids[0].attachment_ids), 1)
# not messages which are sent by `_post_add_create` in previous steps
messages = self.out_invoice.message_ids.filtered(lambda m: m.author_id == self.partner_a)
self.assertEqual(len(messages), 3)
self.assertEqual(messages[0].body, "<p>test message 3</p>")
self.assertEqual(len(messages[0].attachment_ids), 1)

View file

@ -0,0 +1,57 @@
from odoo.fields import Command
from odoo.tests.common import tagged
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
@tagged('post_install', '-at_install')
class TestPortalInvoice(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user_portal = cls._create_new_portal_user()
cls.portal_partner = cls.user_portal.partner_id
def test_portal_my_invoice_detail_not_his_invoice(self):
not_his_invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'price_unit': 100})]
})
not_his_invoice.action_post()
url = f'/my/invoices/{not_his_invoice.id}?report_type=pdf&download=True'
self.authenticate(self.user_portal.login, self.user_portal.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
def test_portal_my_invoice_detail_download_pdf(self):
invoice_with_pdf = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.portal_partner.id,
'invoice_line_ids': [Command.create({'price_unit': 100})]
})
invoice_with_pdf.action_post()
invoice_with_pdf._generate_and_send()
self.assertTrue(invoice_with_pdf.invoice_pdf_report_id)
url = f'/my/invoices/{invoice_with_pdf.id}?report_type=pdf&download=True'
self.authenticate(self.user_portal.login, self.user_portal.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, invoice_with_pdf.invoice_pdf_report_id.raw)
def test_portal_my_invoice_detail_download_proforma(self):
invoice_no_pdf = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.portal_partner.id,
'invoice_line_ids': [Command.create({'price_unit': 100})]
})
invoice_no_pdf.action_post()
self.assertFalse(invoice_no_pdf.invoice_pdf_report_id)
url = f'/my/invoices/{invoice_no_pdf.id}?report_type=pdf&download=True'
self.authenticate(self.user_portal.login, self.user_portal.login)
res = self.url_open(url)
self.assertEqual(res.status_code, 200)
self.assertIn("Proforma", res.content.decode('utf-8'))

View file

@ -1,37 +1,131 @@
from .common import AccountTestInvoicingCommon
from odoo.tests.common import Form, tagged, new_test_user
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo import Command
from odoo.tests import Form, tagged
from odoo.tests.common import new_test_user
@tagged("post_install", "-at_install")
class AccountProductCase(AccountTestInvoicingCommon):
@tagged('post_install', 'post_install_l10n', '-at_install')
class TestProduct(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.internal_user = new_test_user(
cls.env, login="internal_user", groups="base.group_user"
cls.env,
login="internal_user",
groups="base.group_user",
)
cls.account_manager_user = new_test_user(
cls.env,
login="account_manager_user",
groups="account.group_account_manager",
)
def test_internal_user_can_read_product_with_tax_and_tags(self):
"""Internal users need read access to products, no matter their taxes."""
# Add a tag to product_a's default tax
self.company_data["company"].country_id = self.env.ref("base.us")
tax_line_tag = self.env["account.account.tag"].create(
{
"name": "Tax tag",
"applicability": "taxes",
"country_id": self.company_data["company"].country_id.id,
}
)
repartition_lines = (
self.product_a.taxes_id.invoice_repartition_line_ids
| self.product_a.taxes_id.refund_repartition_line_ids
).filtered_domain([("repartition_type", "=", "tax")])
repartition_lines.write({"tag_ids": [Command.link(tax_line_tag.id)]})
tax_line_tag = self.env["account.account.tag"].create({
"name": "Tax tag",
"applicability": "taxes",
})
self.product_a.taxes_id.repartition_line_ids.tag_ids = tax_line_tag
# Check that internal user can read product_a
with Form(
self.product_a.with_user(self.internal_user).with_context(lang="en_US")
) as form_a:
self.env.invalidate_all()
with Form(self.product_a.with_user(self.internal_user)) as form_a:
# The tax string itself is not very important here; we just check
# it has a value and we can read it, so there were no access errors
self.assertTrue(form_a.tax_string)
def test_multi_company_product_tax(self):
""" Ensure default taxes are set for all companies on products with no company set. """
product_without_company = self.env['product.template'].with_context(allowed_company_ids=self.env.company.ids).create({
'name': 'Product Without a Company',
})
product_with_company = self.env['product.template'].with_context(allowed_company_ids=self.env.company.ids).create({
'name': 'Product With a Company',
'company_id': self.company_data['company'].id,
})
companies = self.env['res.company'].sudo().search([])
# Product should have all the default taxes of the other companies.
self.assertRecordValues(product_without_company.sudo(), [{
'taxes_id': companies.account_sale_tax_id.ids,
'supplier_taxes_id': companies.account_purchase_tax_id.ids,
}]) # Take care that inactive default taxes won't be shown on the product
# Product should have only the default tax of the company it belongs to.
self.assertRecordValues(product_with_company.sudo(), [{
'taxes_id': self.company_data['company'].account_sale_tax_id.ids,
'supplier_taxes_id': self.company_data['company'].account_purchase_tax_id.ids,
}])
def test_product_tax_with_company_and_branch(self):
"""Ensure that setting a tax on a product overrides the default tax of branch companies.
as branches share taxes with their parent company."""
parent_company = self.env.company
# Create a branch company and set a default sales tax.
self.env['res.company'].create({
'name': 'Branch Company',
'parent_id': parent_company.id,
'account_sale_tax_id': parent_company.account_sale_tax_id.id,
})
tax_new = self.env['account.tax'].create({
'name': "tax_new",
'amount_type': 'percent',
'amount': 21.0,
'type_tax_use': 'sale',
})
# Create a product in the parent company and set its sales tax to the new tax
product = self.env['product.template'].with_context(allowed_company_ids=[parent_company.id]).create({
'name': 'Product with new Tax',
'taxes_id': tax_new.ids,
})
self.assertEqual(product.taxes_id, tax_new, "The branch company default tax shouldn't be set if we set a different tax on the product from the parent company.")
def test_product_category_parent_account_fallback(self):
"""When no account is set on a product category, accounts should be inherited from parent categories.
Also covers the case where income and expense are defined at different hierarchy levels.
"""
grandparent_income = self.copy_account(self.company_data['default_account_revenue'])
child_expense = self.copy_account(self.company_data['default_account_expense'])
grandparent_categ = self.env['product.category'].create({
'name': 'Grandparent Category',
'property_account_income_categ_id': grandparent_income.id,
})
parent_categ = self.env['product.category'].create({
'name': 'Parent Category',
'parent_id': grandparent_categ.id,
'property_account_income_categ_id': False,
'property_account_expense_categ_id': False,
})
child_categ = self.env['product.category'].create({
'name': 'Child Category',
'parent_id': parent_categ.id,
'property_account_income_categ_id': False,
'property_account_expense_categ_id': child_expense.id,
})
product = self.env['product.product'].create({
'name': 'Test Product',
'categ_id': child_categ.id,
})
invoice, bill = self.env['account.move'].create([
{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'product_id': product.id})],
},
{
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'product_id': product.id})],
},
])
self.assertEqual(invoice.invoice_line_ids.account_id, grandparent_income,
"Customer invoice line should use grandparent category's income account")
self.assertEqual(bill.invoice_line_ids.account_id, child_expense,
"Vendor bill line should use child category's expense account")

View file

@ -0,0 +1,72 @@
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestMergePartner(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Partner = cls.env['res.partner']
cls.Bank = cls.env['res.partner.bank']
cls.Payment = cls.env['account.payment']
# Create partners
cls.partner1 = cls.Partner.create({'name': 'Partner 1', 'email': 'partner1@example.com'})
cls.partner2 = cls.Partner.create({'name': 'Partner 2', 'email': 'partner2@example.com'})
cls.partner3 = cls.Partner.create({'name': 'Partner 3', 'email': 'partner3@example.com'})
# Create bank accounts
cls.bank1 = cls.Bank.create({'acc_number': '12345', 'partner_id': cls.partner1.id})
cls.bank2 = cls.Bank.create({'acc_number': '67890', 'partner_id': cls.partner2.id})
cls.bank3 = cls.Bank.create({'acc_number': '12345', 'partner_id': cls.partner3.id})
# Create payments linked to bank accounts
cls.payment1 = cls.Payment.create({
'partner_id': cls.partner1.id,
'partner_bank_id': cls.bank1.id,
'amount': 100,
'payment_type': 'outbound',
'payment_method_id': cls.env.ref('account.account_payment_method_manual_out').id,
'journal_id': cls.company_data['default_journal_bank'].id,
})
cls.payment2 = cls.Payment.create({
'partner_id': cls.partner2.id,
'partner_bank_id': cls.bank2.id,
'amount': 200,
'payment_type': 'outbound',
'payment_method_id': cls.env.ref('account.account_payment_method_manual_out').id,
'journal_id': cls.company_data['default_journal_bank'].id,
})
cls.payment3 = cls.Payment.create({
'partner_id': cls.partner3.id,
'partner_bank_id': cls.bank3.id,
'amount': 200,
'payment_type': 'outbound',
'payment_method_id': cls.env.ref('account.account_payment_method_manual_out').id,
'journal_id': cls.company_data['default_journal_bank'].id,
})
def test_merge_partners_with_bank_accounts_linked_to_payments(self):
wizard = self.env['base.partner.merge.automatic.wizard'].create({})
wizard._merge([self.partner1.id, self.partner2.id], self.partner1)
self.assertFalse(self.partner2.exists(), "Source partner should be deleted after merge")
self.assertTrue(self.partner1.exists(), "Destination partner should exist after merge")
self.assertEqual(self.payment1.partner_id, self.partner1, "Payment should be linked to the destination partner")
self.assertEqual(self.payment2.partner_id, self.partner1, "Payment should be linked to the destination partner")
self.assertEqual(self.payment1.partner_bank_id.partner_id, self.partner1, "Payment's bank account should belong to the destination partner")
self.assertEqual(self.payment2.partner_bank_id.partner_id, self.partner1, "Payment's bank account should belong to the destination partner")
def test_merge_partners_with_duplicate_bank_accounts_linked_to_payments(self):
wizard = self.env['base.partner.merge.automatic.wizard'].create({})
wizard._merge([self.partner1.id, self.partner3.id], self.partner1)
self.assertFalse(self.partner3.exists(), "Source partner should be deleted after merge")
self.assertTrue(self.partner1.exists(), "Destination partner should exist after merge")
self.assertEqual(self.payment1.partner_id, self.partner1, "Payment should be linked to the destination partner")
self.assertEqual(self.payment3.partner_id, self.partner1, "Payment should be linked to the destination partner")
self.assertEqual(self.payment1.partner_bank_id.partner_id, self.partner1, "Payment's bank account should belong to the destination partner")
self.assertEqual(self.payment3.partner_bank_id.partner_id, self.partner1, "Payment's bank account should belong to the destination partner")

View file

@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
from odoo.tests.common import Form, TransactionCase
from odoo.exceptions import ValidationError
from odoo.tests import Form, tagged, TransactionCase
from odoo import fields, api, SUPERUSER_ID, Command
from odoo.exceptions import ValidationError, UserError
from odoo.tools import mute_logger
from dateutil.relativedelta import relativedelta
@ -11,13 +10,13 @@ from freezegun import freeze_time
from functools import reduce
import json
import psycopg2
from unittest.mock import patch
from unittest.mock import patch, Mock
class TestSequenceMixinCommon(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.company_data['company'].write({'fiscalyear_last_day': "31", 'fiscalyear_last_month': "3"})
cls.test_move = cls.create_move()
@ -42,6 +41,13 @@ class TestSequenceMixinCommon(AccountTestInvoicingCommon):
move.action_post()
return move
def assertMoveName(cls, move, expected_name):
if move.name_placeholder:
cls.assertFalse(move.name, f"This move is potentially the first of the sequence, it shouldn't have a name while it is not posted. Got '{move.name}'.")
cls.assertEqual(move.name_placeholder, expected_name, f"This move is potentially the first of the sequence, it doesn't have a name but a placeholder name which is currently '{move.name_placeholder}'. You expected '{expected_name}'.")
else:
cls.assertEqual(move.name, expected_name, f"Expected '{expected_name}' but got '{move.name}'.")
@tagged('post_install', '-at_install')
class TestSequenceMixin(TestSequenceMixinCommon):
@ -58,23 +64,23 @@ class TestSequenceMixin(TestSequenceMixinCommon):
"""Change the sequence when we change the date iff it has never been posted."""
# Check setup
self.assertEqual(self.test_move.state, 'draft')
self.assertEqual(self.test_move.name, 'MISC/2016/01/0001')
self.assertEqual(self.test_move.name_placeholder, 'MISC/15-16/01/0001')
self.assertEqual(fields.Date.to_string(self.test_move.date), '2016-01-01')
# Never posetd, the number must change if we change the date
self.test_move.date = '2020-02-02'
self.assertEqual(self.test_move.name, 'MISC/2020/02/0001')
self.assertMoveName(self.test_move, 'MISC/19-20/02/0001')
# We don't recompute user's input when posting
self.test_move.name = 'MyMISC/2020/0000001'
self.test_move.action_post()
self.assertEqual(self.test_move.name, 'MyMISC/2020/0000001')
self.assertMoveName(self.test_move, 'MyMISC/2020/0000001')
# Has been posted, and it doesn't change anymore
self.test_move.button_draft()
self.test_move.date = '2020-01-02'
self.test_move.action_post()
self.assertEqual(self.test_move.name, 'MyMISC/2020/0000001')
self.assertMoveName(self.test_move, 'MyMISC/2020/0000001')
def test_sequence_change_date_with_quick_edit_mode(self):
"""
@ -96,16 +102,17 @@ class TestSequenceMixin(TestSequenceMixinCommon):
}),
]
})
self.assertMoveName(bill, 'BILL/15-16/01/0001')
bill = bill.copy({'date': '2016-02-01'})
self.assertEqual(bill.name, 'BILL/2016/02/0001')
self.assertMoveName(bill, 'BILL/15-16/02/0001')
with Form(bill) as bill_form:
bill_form.date = '2016-02-02'
self.assertEqual(bill_form.name, 'BILL/2016/02/0001')
self.assertMoveName(bill_form, 'BILL/15-16/02/0001')
bill_form.date = '2016-03-01'
self.assertEqual(bill_form.name, 'BILL/2016/03/0001')
self.assertMoveName(bill_form, 'BILL/15-16/03/0001')
bill_form.date = '2017-01-01'
self.assertEqual(bill_form.name, 'BILL/2017/01/0001')
self.assertMoveName(bill_form, 'BILL/16-17/01/0001')
invoice = self.env['account.move'].create({
'partner_id': 1,
@ -119,14 +126,14 @@ class TestSequenceMixin(TestSequenceMixinCommon):
]
})
self.assertEqual(invoice.name, 'INV/2016/00001')
self.assertMoveName(invoice, 'INV/15-16/0001')
with Form(invoice) as invoice_form:
invoice_form.date = '2016-01-02'
self.assertEqual(invoice_form.name, 'INV/2016/00001')
self.assertMoveName(invoice_form, 'INV/15-16/0001')
invoice_form.date = '2016-02-02'
self.assertEqual(invoice_form.name, 'INV/2016/00001')
self.assertMoveName(invoice_form, 'INV/15-16/0001')
invoice_form.date = '2017-01-01'
self.assertEqual(invoice_form.name, 'INV/2017/00001')
self.assertMoveName(invoice_form, 'INV/16-17/0001')
def test_sequence_empty_editable_with_quick_edit_mode(self):
""" Ensure the names of all but the first moves in a period are empty and editable in quick edit mode """
@ -136,6 +143,7 @@ class TestSequenceMixin(TestSequenceMixinCommon):
'partner_id': 1,
'move_type': 'in_invoice',
'date': '2016-01-01',
'invoice_date': '2016-01-01',
'line_ids': [
Command.create({
'name': 'line',
@ -144,15 +152,14 @@ class TestSequenceMixin(TestSequenceMixinCommon):
]
})
# First move in a period gets a name
self.assertEqual(bill_1.name, 'BILL/2016/01/0001')
self.assertMoveName(bill_1, 'BILL/15-16/01/0001')
bill_2 = bill_1.copy({'date': '2016-01-02'})
with Form(bill_2) as bill_2_form:
# Subsequent moves in the same period get an empty editable name in draft mode
self.assertFalse(bill_2_form.name)
bill_2_form.name = 'BILL/2016/01/0002'
self.assertEqual(bill_2_form.name, 'BILL/2016/01/0002')
bill_2.name = 'BILL/15-16/01/0002'
self.assertMoveName(bill_2_form, 'BILL/15-16/01/0001')
bill_3 = bill_1.copy({'date': '2016-01-03'})
bill_4 = bill_1.copy({'date': '2016-01-04'})
@ -160,16 +167,16 @@ class TestSequenceMixin(TestSequenceMixinCommon):
# Same works with updating multiple moves
with Form(bill_3) as bill_3_form:
self.assertEqual(bill_3_form.name, 'BILL/2016/02/0001')
self.assertMoveName(bill_3_form, 'BILL/15-16/02/0001')
with Form(bill_4) as bill_4_form:
self.assertFalse(bill_4_form.name)
bill_4_form.name = 'BILL/2016/02/0002'
self.assertEqual(bill_4_form.name, 'BILL/2016/02/0002')
bill_4.name = 'BILL/15-16/02/0002'
self.assertMoveName(bill_4_form, 'BILL/15-16/02/0001')
def test_sequence_draft_change_date(self):
# When a draft entry is added to an empty period, it should get a name.
# When a draft entry with a name is moved to a period already having entries, its name should be reset to '/'.
# When a draft entry with a name is moved to a period already having entries, its name should be reset to False.
new_move = self.test_move.copy({'date': '2016-02-01'})
new_multiple_move_1 = self.test_move.copy({'date': '2016-03-01'})
@ -177,36 +184,37 @@ class TestSequenceMixin(TestSequenceMixinCommon):
new_moves = new_multiple_move_1 + new_multiple_move_2
# Empty period, so a name should be set
self.assertEqual(new_move.name, 'MISC/2016/02/0001')
self.assertEqual(new_multiple_move_1.name, 'MISC/2016/03/0001')
self.assertEqual(new_multiple_move_2.name, 'MISC/2016/04/0001')
self.assertMoveName(new_move, 'MISC/15-16/02/0001')
self.assertMoveName(new_multiple_move_1, 'MISC/15-16/03/0001')
self.assertMoveName(new_multiple_move_2, 'MISC/16-17/04/0001')
# Move to an existing period with another move in it
# Move to an existing period with a posted move in it
self.test_move.action_post()
new_move.date = fields.Date.to_date('2016-01-10')
new_moves.date = fields.Date.to_date('2016-01-15')
# Not an empty period, so names should be reset to '/' (draft)
self.assertEqual(new_move.name, '/')
self.assertEqual(new_multiple_move_1.name, '/')
self.assertEqual(new_multiple_move_2.name, '/')
# Not an empty period, so names should be reset to False (draft)
self.assertMoveName(new_move, False)
self.assertMoveName(new_multiple_move_1, False)
self.assertMoveName(new_multiple_move_2, False)
# Move back to a period with no moves in it
new_move.date = fields.Date.to_date('2016-02-01')
new_moves.date = fields.Date.to_date('2016-03-01')
# All moves in the previously empty periods should be given a name instead of `/`
self.assertEqual(new_move.name, 'MISC/2016/02/0001')
self.assertEqual(new_multiple_move_1.name, 'MISC/2016/03/0001')
# Since this is the second one in the same period, it should remain `/`
self.assertEqual(new_multiple_move_2.name, '/')
self.assertMoveName(new_move, 'MISC/15-16/02/0001')
self.assertMoveName(new_multiple_move_1, 'MISC/15-16/03/0001')
# Since this is the second one in the same period, both have the same pending name
self.assertMoveName(new_multiple_move_2, 'MISC/15-16/03/0001')
# Move both moves back to different periods, both with already moves in it.
# Move both moves back to different periods, both with already moves in it. One has a posted move in the sequence, the other not.
new_multiple_move_1.date = fields.Date.to_date('2016-01-10')
new_multiple_move_2.date = fields.Date.to_date('2016-02-10')
# Moves are not in empty periods, so names should be set to '/' (draft)
self.assertEqual(new_multiple_move_1.name, '/')
self.assertEqual(new_multiple_move_2.name, '/')
# Moves are not in empty periods, but only the first hsa a posted move. So the first draft should be False and the second should get a name.
self.assertMoveName(new_multiple_move_1, False)
self.assertMoveName(new_multiple_move_2, 'MISC/15-16/02/0001')
# Change the journal of the last two moves (empty)
journal = self.env['account.journal'].create({
@ -217,15 +225,24 @@ class TestSequenceMixin(TestSequenceMixinCommon):
new_moves.journal_id = journal
# Both moves should be assigned a name, since no moves are in the journal and they are in different periods.
self.assertEqual(new_multiple_move_1.name, 'AJ/2016/01/0001')
self.assertEqual(new_multiple_move_2.name, 'AJ/2016/02/0001')
self.assertMoveName(new_multiple_move_1, 'AJ/15-16/01/0001')
self.assertMoveName(new_multiple_move_2, 'AJ/15-16/02/0001')
# When the date is removed in the form view, the name should not recompute
with Form(new_multiple_move_1) as move_form:
move_form.date = False
self.assertEqual(new_multiple_move_1.name, 'AJ/2016/01/0001')
move_form.date = fields.Date.to_date('2016-01-11')
self.assertMoveName(new_multiple_move_1, 'AJ/15-16/01/0001')
move_form.date = fields.Date.to_date('2016-01-10')
def test_sequence_draft_change_date_with_new_sequence(self):
invoice_1 = self.test_move.copy({'date': '2016-02-01', 'journal_id': self.company_data['default_journal_sale'].id})
invoice_2 = invoice_1.copy({'date': '2016-02-02'})
self.assertMoveName(invoice_2, 'INV/15-16/0001')
invoice_1.name = 'INV/15-16/02/001'
invoice_2.date = '2016-03-01'
self.assertMoveName(invoice_2, 'INV/15-16/03/001')
def test_sequence_draft_first_of_period(self):
"""
| Step | Move | Action | Date | Name |
@ -236,74 +253,69 @@ class TestSequenceMixin(TestSequenceMixinCommon):
| 4 | `A` | Cancel | 2023-02-01 | `2023/02/0001` | -> Assert
"""
move_a = self.test_move.copy({'date': '2023-02-01'})
self.assertEqual(move_a.name, 'MISC/2023/02/0001')
self.assertMoveName(move_a, 'MISC/22-23/02/0001')
move_b = self.test_move.copy({'date': '2023-02-02'})
self.assertEqual(move_b.name, '/')
self.assertMoveName(move_b, 'MISC/22-23/02/0001')
move_b.action_post()
self.assertEqual(move_b.name, 'MISC/2023/02/0002')
self.assertMoveName(move_b, 'MISC/22-23/02/0001')
# The first sequence slot is now taken by move_b, move_a's name and placeholder should be False.
move_a.button_cancel()
self.assertEqual(move_a.name, 'MISC/2023/02/0001')
self.assertMoveName(move_a, False)
def test_journal_sequence(self):
self.assertEqual(self.test_move.name, 'MISC/2016/01/0001')
self.assertMoveName(self.test_move, 'MISC/15-16/01/0001')
self.test_move.action_post()
self.assertEqual(self.test_move.name, 'MISC/2016/01/0001')
self.assertMoveName(self.test_move, 'MISC/15-16/01/0001')
copy1 = self.create_move(date=self.test_move.date)
self.assertEqual(copy1.name, '/')
self.assertMoveName(copy1, False)
copy1.action_post()
self.assertEqual(copy1.name, 'MISC/2016/01/0002')
self.assertMoveName(copy1, 'MISC/15-16/01/0002')
copy2 = self.create_move(date=self.test_move.date)
new_journal = self.test_move.journal_id.copy()
new_journal.code = "MISC2"
copy2.journal_id = new_journal
self.assertEqual(copy2.name, 'MISC2/2016/01/0001')
with Form(copy2) as move_form: # It is editable in the form
with mute_logger('odoo.tests.common.onchange'):
move_form.name = 'MyMISC/2016/0001'
self.assertIn(
'The sequence will restart at 1 at the start of every year',
move_form._perform_onchange(['name'])['warning']['message'],
)
move_form.journal_id = self.test_move.journal_id
self.assertEqual(move_form.name, '/')
move_form.journal_id = new_journal
self.assertEqual(move_form.name, 'MISC2/2016/01/0001')
with mute_logger('odoo.tests.common.onchange'):
move_form.name = 'MyMISC/2016/0001'
self.assertIn(
'The sequence will restart at 1 at the start of every year',
move_form._perform_onchange(['name'])['warning']['message'],
)
self.assertMoveName(copy2, 'MISC2/15-16/01/0001')
copy2.action_post()
self.assertEqual(copy2.name, 'MyMISC/2016/0001')
copy2.button_draft()
with Form(copy2) as move_form: # It is editable in the form
with self.assertLogs('odoo.tests.form') as cm:
move_form.name = 'MyMISC/2016/0001'
self.assertTrue(cm.output[0].startswith('WARNING:odoo.tests.form.onchange:'))
self.assertIn('The sequence will restart at 1 at the start of every year', cm.output[0])
copy2.name = False # Can't modify journal_id if name is set
copy2.journal_id = self.test_move.journal_id
self.assertMoveName(copy2, False)
copy2.journal_id = new_journal
self.assertMoveName(copy2, 'MISC2/15-16/01/0001')
copy2.name = 'MyMISC/2016/0001'
copy2.action_post()
self.assertMoveName(copy2, 'MyMISC/2016/0001')
copy3 = self.create_move(date=copy2.date, journal=new_journal)
self.assertEqual(copy3.name, '/')
with self.assertRaises(AssertionError):
with Form(copy2) as move_form: # It is not editable in the form
move_form.name = 'MyMISC/2016/0002'
copy3.action_post()
self.assertEqual(copy3.name, 'MyMISC/2016/0002')
self.assertMoveName(copy3, False)
copy3.name = 'MISC2/2016/00002'
copy3.action_post()
copy4 = self.create_move(date=copy2.date, journal=new_journal)
copy4.action_post()
self.assertEqual(copy4.name, 'MISC2/2016/00003')
self.assertMoveName(copy4, 'MISC2/2016/00003')
copy5 = self.create_move(date=copy2.date, journal=new_journal)
copy5.date = '2021-02-02'
copy5.action_post()
self.assertEqual(copy5.name, 'MISC2/2021/00001')
self.assertMoveName(copy5, 'MISC2/2021/00001')
copy5.name = 'N\'importe quoi?'
copy6 = self.create_move(date=copy5.date, journal=new_journal)
copy6.action_post()
self.assertEqual(copy6.name, 'N\'importe quoi?1')
self.assertMoveName(copy6, 'N\'importe quoi?1')
def test_journal_sequence_format(self):
"""Test different format of sequences and what it becomes on another period"""
@ -344,9 +356,9 @@ class TestSequenceMixin(TestSequenceMixinCommon):
self.test_move.name = f"{prefix}1"
for c in range(2, 25):
copy = self.create_move(date=self.test_move.date)
copy.name = "/"
copy.name = False
copy.action_post()
self.assertEqual(copy.name, f"{prefix}{c}")
self.assertMoveName(copy, f"{prefix}{c}")
def test_journal_sequence_multiple_type(self):
"""Domain is computed accordingly to different types."""
@ -364,12 +376,12 @@ class TestSequenceMixin(TestSequenceMixinCommon):
all_moves = (entry + entry2 + invoice + invoice2 + refund + refund2)
all_moves.name = False
all_moves.action_post()
self.assertEqual(entry.name, 'MISC/2016/01/0002')
self.assertEqual(entry2.name, 'MISC/2016/01/0003')
self.assertEqual(invoice.name, 'INV/2016/00001')
self.assertEqual(invoice2.name, 'INV/2016/00002')
self.assertEqual(refund.name, 'RINV/2016/00001')
self.assertEqual(refund2.name, 'RINV/2016/00002')
self.assertEqual(entry.name, 'MISC/15-16/01/0001')
self.assertEqual(entry2.name, 'MISC/15-16/01/0002')
self.assertEqual(invoice.name, 'INV/15-16/0001')
self.assertEqual(invoice2.name, 'INV/15-16/0002')
self.assertEqual(refund.name, 'RINV/15-16/0001')
self.assertEqual(refund2.name, 'RINV/15-16/0002')
def test_journal_sequence_groupby_compute(self):
"""The grouping optimization is correctly done."""
@ -422,20 +434,20 @@ class TestSequenceMixin(TestSequenceMixinCommon):
self.create_move(date='2020-01-01', name='00000876-G 0002/2020')
next_move = self.create_move(date='2020-01-01')
next_move.action_post()
self.assertEqual(next_move.name, '00000876-G 0002/2021') # Wait, I didn't want this!
self.assertMoveName(next_move, '00000876-G 0002/2021') # Wait, I didn't want this!
next_move.button_draft()
next_move.name = False
next_move.journal_id.sequence_override_regex = r'^(?P<seq>\d*)(?P<suffix1>.*?)(?P<year>(\d{4})?)(?P<suffix2>)$'
next_move.action_post()
self.assertEqual(next_move.name, '00000877-G 0002/2020') # Pfew, better!
self.assertMoveName(next_move, '00000877-G 0002/2020') # Pfew, better!
next_move = self.create_move(date='2020-01-01')
next_move.action_post()
self.assertEqual(next_move.name, '00000878-G 0002/2020')
self.assertMoveName(next_move, '00000878-G 0002/2020')
next_move = self.create_move(date='2017-05-02')
next_move.action_post()
self.assertEqual(next_move.name, '00000001-G 0002/2017')
self.assertMoveName(next_move, '00000001-G 0002/2017')
def test_journal_override_sequence_regex_year(self):
"""Override the sequence regex with a year syntax not matching the draft invoice name"""
@ -467,8 +479,8 @@ class TestSequenceMixin(TestSequenceMixinCommon):
# check if year is correctly extracted
with self.assertRaises(ValidationError):
self.create_move(date='2022-01-01', name='MISC/2021/22/00001') # year does not match
self.create_move(date='2022-01-01', name='MISC/2022/22/00001') # fix the year in the name
self.create_move(date='2022-01-01', name='MISC/2021/22/00001', post=True) # year does not match
self.create_move(date='2022-01-01', name='MISC/2022/22/00001', post=True) # fix the year in the name
def test_journal_sequence_ordering(self):
"""Entries are correctly sorted when posting multiple at once."""
@ -485,20 +497,20 @@ class TestSequenceMixin(TestSequenceMixinCommon):
copies[4].date = '2019-03-05'
copies[5].date = '2019-03-05'
# that entry is actualy the first one of the period, so it already has a name
# set it to '/' so that it is recomputed at post to be ordered correctly.
copies[0].name = '/'
# set it to False so that it is recomputed at post to be ordered correctly.
copies[0].name = False
copies.action_post()
# Ordered by date
self.assertEqual(copies[0].name, 'XMISC/2019/00002')
self.assertEqual(copies[1].name, 'XMISC/2019/00005')
self.assertEqual(copies[2].name, 'XMISC/2019/00006')
self.assertEqual(copies[3].name, 'XMISC/2019/00001')
self.assertEqual(copies[4].name, 'XMISC/2019/00003')
self.assertEqual(copies[5].name, 'XMISC/2019/00004')
self.assertMoveName(copies[0], 'XMISC/2019/00002')
self.assertMoveName(copies[1], 'XMISC/2019/00005')
self.assertMoveName(copies[2], 'XMISC/2019/00006')
self.assertMoveName(copies[3], 'XMISC/2019/00001')
self.assertMoveName(copies[4], 'XMISC/2019/00003')
self.assertMoveName(copies[5], 'XMISC/2019/00004')
# Can't have twice the same name
with self.assertRaises(psycopg2.DatabaseError), mute_logger('odoo.sql_db'), self.env.cr.savepoint():
with self.assertRaises(psycopg2.DatabaseError), mute_logger('odoo.sql_db'):
copies[0].name = 'XMISC/2019/00001'
# Lets remove the order by date
@ -537,7 +549,7 @@ class TestSequenceMixin(TestSequenceMixinCommon):
wizard.save().resequence()
self.assertEqual(copies[3].state, 'posted')
self.assertEqual(copies[5].name, 'XMISC/2019/10005')
self.assertMoveName(copies[5], 'XMISC/2019/10005')
self.assertEqual(copies[5].state, 'draft')
def test_journal_resequence_in_between_2_years_pattern(self):
@ -580,6 +592,40 @@ class TestSequenceMixin(TestSequenceMixinCommon):
{'name': 'XMISC/23-24/00002', 'state': 'posted'},
))
def test_sequence_staggered_year(self):
"""The sequence is correctly computed when the year is staggered."""
self.env.company.quick_edit_mode = "out_and_in_invoices"
self.env.company.fiscalyear_last_day = 15
self.env.company.fiscalyear_last_month = '4'
# First bill in second half of first month of the fiscal year, which is
# the start of the fiscal year
bill = self.env['account.move'].create({
'partner_id': 1,
'move_type': 'in_invoice',
'date': '2024-04-17',
'line_ids': [
Command.create({
'name': 'line',
'account_id': self.company_data['default_account_revenue'].id,
}),
]
})
self.assertMoveName(bill, 'BILL/24-25/04/0001')
# First bill in first half of first month of the fiscal year, which is
# the end of the fiscal year
bill_copy = bill.copy({'date': '2024-04-10', 'invoice_date': '2024-04-10'})
bill_copy.action_post()
self.assertMoveName(bill_copy, 'BILL/23-24/04/0001')
# Second bill in first half of first month
bill_copy_2 = bill.copy({'date': '2024-04-11', 'invoice_date': '2024-04-11'})
bill_copy_2.action_post()
self.assertMoveName(bill_copy_2, 'BILL/23-24/04/0002')
# Second bill in second half of first month
bill_copy_3 = bill.copy({'date': '2024-04-18', 'invoice_date': '2024-04-18'})
bill_copy_3.action_post()
self.assertMoveName(bill_copy_3, 'BILL/24-25/04/0001')
def test_sequence_get_more_specific(self):
"""There is the ability to change the format (i.e. from yearly to montlhy)."""
# Start with a continuous sequence
@ -645,14 +691,15 @@ class TestSequenceMixin(TestSequenceMixinCommon):
'code': 'AJ',
})
move = self.env['account.move'].create({})
self.assertEqual(move.name, 'MISC/2021/10/0001')
self.assertMoveName(move, 'MISC/21-22/10/0001')
with Form(move) as move_form:
move_form.journal_id = journal
self.assertEqual(move.name, 'AJ/2021/10/0001')
self.assertMoveName(move, 'AJ/21-22/10/0001')
def test_sequence_move_name_related_field_well_computed(self):
AccountMove = type(self.env['account.move'])
_compute_name = AccountMove._compute_name
def _flushing_compute_name(self):
self.env['account.move.line'].flush_model(fnames=['move_name'])
_compute_name(self)
@ -670,6 +717,7 @@ class TestSequenceMixin(TestSequenceMixinCommon):
for move in payments.move_id:
self.assertRecordValues(move.line_ids, [{'move_name': move.name}] * len(move.line_ids))
self.assertRecordValues(move.line_ids, [{'name': "Manual Payment"}] * len(move.line_ids))
def test_resequence_payment_and_non_payment_without_payment_sequence(self):
"""Resequence wizard could be open for different move type if the payment sequence is set to False on the journal."""
@ -699,13 +747,101 @@ class TestSequenceMixin(TestSequenceMixinCommon):
# we need to create another move to higer the sequence
self.create_move(date='2025-10-17', post=True)
move2.journal_id = move2.journal_id
self.assertEqual(move2.name, 'MISC/2025/10/0002')
self.assertEqual(move2.name, 'MISC/25-26/10/0002')
def test_limit_savepoint(self):
with patch.object(self.env.cr, 'savepoint', Mock(wraps=self.env.cr.savepoint)) as mock:
self.create_move(date='2020-01-01', post=True)
mock.assert_called_once()
with patch.object(self.env.cr, 'savepoint', Mock(wraps=self.env.cr.savepoint)) as mock:
self.create_move(date='2020-01-01', post=True)
mock.assert_not_called()
with patch.object(self.env.cr, 'savepoint', Mock(wraps=self.env.cr.savepoint)) as mock:
self.create_move(date='2021-01-01', post=True)
mock.assert_called_once()
@tagged('post_install', '-at_install')
class TestSequenceGaps(TestSequenceMixinCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
move_1 = cls.create_move()
move_2 = cls.create_move()
move_3 = cls.create_move()
all_moves = move_1 + move_2 + move_3
all_moves.action_post()
cls.all_moves = all_moves
def test_basic(self):
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, False])
def test_first(self):
new_move = self.create_move(name="NEW/000001")
new_move.action_post()
self.assertEqual(new_move.made_sequence_gap, False)
def test_unlink(self):
self.all_moves[0].button_draft()
self.all_moves[0].unlink()
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [True, False])
self.all_moves[1].button_draft()
self.all_moves[1].unlink()
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [True])
def test_unlink_2(self):
self.all_moves[1].button_draft()
self.all_moves[1].unlink()
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [False, True])
self.all_moves[0].button_draft()
self.all_moves[0].unlink()
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [True])
def test_change_sequence(self):
previous = self.all_moves[1].name
self.all_moves[1].name = '/'
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, True])
self.all_moves[1].name = previous
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, False])
def test_change_multi(self):
self.all_moves[0].name = '/'
self.all_moves[1].name = '/'
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, True])
def test_change_multi_2(self):
self.all_moves[1].name = '/'
self.all_moves[0].name = '/'
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, True])
def test_null_change(self):
self.all_moves[1].name = self.all_moves[1].name
self.assertEqual(self.all_moves.mapped('made_sequence_gap'), [False, False, False])
def test_create_fill_gap(self):
previous = self.all_moves[1].name
self.all_moves[1].button_draft()
self.all_moves[1].unlink()
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [False, True])
new_move = self.create_move(name=previous)
self.assertEqual(self.all_moves.exists().mapped('made_sequence_gap'), [False, False])
self.assertEqual(new_move.made_sequence_gap, True)
new_move.action_post()
self.assertEqual(new_move.made_sequence_gap, False)
def test_create_gap(self):
last = self.all_moves[2].name
format_string, format_values = self.all_moves[0]._get_sequence_format_param(last)
format_values['seq'] = format_values['seq'] + 10
new_move = self.create_move(name=format_string.format(**format_values))
new_move.action_post()
self.assertEqual(new_move.made_sequence_gap, True)
@tagged('post_install', '-at_install')
class TestSequenceMixinDeletion(TestSequenceMixinCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
journal = cls.env['account.journal'].create({
'name': 'Test sequences - deletion',
'code': 'SEQDEL',
@ -763,9 +899,9 @@ class TestSequenceMixinConcurrency(TransactionCase):
'date': fields.Date.from_string('2016-01-01'),
'line_ids': [(0, 0, {'name': 'name', 'account_id': account.id})]
}] * 3)
moves.name = '/'
moves.name = False
moves[0].action_post()
self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', '/', '/'])
self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', False, False])
env.cr.commit()
self.data = {
'move_ids': moves.ids,
@ -783,7 +919,7 @@ class TestSequenceMixinConcurrency(TransactionCase):
with self.env.registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
moves = env['account.move'].browse(self.data['move_ids'])
moves.button_draft()
moves.filtered(lambda x: x.state in ('posted', 'cancel')).button_draft()
moves.posted_before = False
moves.unlink()
journal = env['account.journal'].browse(self.data['journal_id'])
@ -814,6 +950,11 @@ class TestSequenceMixinConcurrency(TransactionCase):
# check the values
moves = env0['account.move'].browse(self.data['move_ids'])
self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', 'CT/2016/01/0002', 'CT/2016/01/0003'])
self.assertEqual(moves.mapped('sequence_prefix'), ['CT/2016/01/', 'CT/2016/01/', 'CT/2016/01/'])
self.assertEqual(moves.mapped('sequence_number'), [1, 2, 3])
self.assertEqual(moves.mapped('made_sequence_gap'), [False, False, False])
for line in moves.line_ids:
self.assertEqual(line.move_name, line.move_id.name)
def test_sequence_concurency_no_useless_lock(self):
"""Do not lock needlessly when the sequence is not computed"""
@ -834,4 +975,4 @@ class TestSequenceMixinConcurrency(TransactionCase):
# check the values
moves = env0['account.move'].browse(self.data['move_ids'])
self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', '/', 'CT/2016/01/0002'])
self.assertEqual(moves.mapped('name'), ['CT/2016/01/0001', False, 'CT/2016/01/0002'])

View file

@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.exceptions import UserError
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestSettings(AccountTestInvoicingCommon):
def test_switch_taxB2B_taxB2C(self):
"""
Since having users both in the tax B2B and tax B2C groups raise,
modifications of the settings must be done in the right order;
otherwise it is impossible to change the settings.
"""
# at each setting change, all users should be removed from one group and added to the other
# so picking an arbitrary witness should be equivalent to checking that everything worked.
config = self.env['res.config.settings'].create({})
self.switch_tax_settings(config)
def switch_tax_settings(self, config):
config.show_line_subtotals_tax_selection = "tax_excluded"
config.flush_recordset()
config.execute()
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), True)
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), False)
config.show_line_subtotals_tax_selection = "tax_included"
config.flush_recordset()
config.execute()
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), False)
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), True)
config.show_line_subtotals_tax_selection = "tax_excluded"
config.flush_recordset()
config.execute()
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_excluded'), True)
self.assertEqual(self.env.user.has_group('account.group_show_line_subtotals_tax_included'), False)
def test_switch_taxB2B_taxB2C_multicompany(self):
"""
Contrarily to the (apparently reasonable) assumption that adding users
to group and removing them was symmetrical, it may not be the case
if one is done in SQL and the other via the ORM.
Because the latter automatically takes into account record rules that
might make some users invisible.
This one is identical to the previous, except that we do the actions
with a non-superuser user, and in a new company with one user in common
with another company which has a different taxB2X setting.
"""
user = self.env.ref('base.user_admin')
company = self.env['res.company'].create({'name': 'oobO'})
user.write({'company_ids': [(4, company.id)], 'company_id': company.id})
Settings = self.env['res.config.settings'].with_user(user.id)
config = Settings.create({})
self.switch_tax_settings(config)
def test_switch_company_currency(self):
"""
A user should not be able to switch the currency of another company
when that company already has posted account move lines.
"""
# Create company A (user's company)
company_a = self.env['res.company'].create({
'name': "Company A",
})
# Get company B from test setup
company_b = self.company_data['company']
# Create a purchase journal for company B
journal = self.env['account.journal'].create({
'name': "Vendor Bills Journal",
'code': "VEND",
'type': "purchase",
'company_id': company_b.id,
'currency_id': company_b.currency_id.id,
})
# Create an invoice for company B
invoice = self.env['account.move'].create({
'move_type': "in_invoice",
'company_id': company_b.id,
'journal_id': journal.id,
})
invoice.currency_id = self.env.ref('base.EUR').id
# Add a line to the invoice using an expense account
self.env['account.move.line'].create({
'move_id': invoice.id,
'account_id': self.company_data["default_account_expense"].id,
'name': "Test Invoice Line",
'company_id': company_b.id,
})
# Create a user that only belongs to company A
user = self.env['res.users'].create({
'name': "User A",
'login': "user_a@example.com",
'email': "user_a@example.com",
'company_id': company_a.id,
'company_ids': [Command.set([company_a.id])],
'groups_id': [Command.set([
self.env.ref('base.group_system').id,
self.env.ref('base.group_erp_manager').id,
self.env.ref('account.group_account_user').id,
])],
})
# Try to change company B's currency as user A (should raise UserError)
user_env = self.env(user=user)
with self.assertRaises(UserError):
user_env['res.company'].browse(company_b.id).write({
'currency_id': self.env.ref('base.EUR').id,
})

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestSetupWizard(AccountTestInvoicingCommon):
def test_setup_bank_account(self):
"""
Test that no error is raised when creating the bank setup wizard
"""
wizard = self.env['account.setup.bank.manual.config'].create([
{
'num_journals_without_account_bank': 1,
'linked_journal_id': False,
'acc_number': 'BE15001559627230',
'bank_id': self.env['res.bank'].create({'name': 'Test bank'}).id,
'bank_bic': False
}
])
self.assertTrue(wizard)

View file

@ -0,0 +1,187 @@
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.account.tools import (
is_valid_structured_reference_be,
is_valid_structured_reference_fi,
is_valid_structured_reference_no_se,
is_valid_structured_reference_si,
is_valid_structured_reference_nl,
is_valid_structured_reference_iso,
is_valid_structured_reference,
is_valid_structured_reference_for_country,
)
@tagged('standard', 'at_install')
class StructuredReferenceTest(TransactionCase):
def test_structured_reference_iso(self):
# Accepts references in structured format
self.assertTrue(is_valid_structured_reference_iso(' RF18 5390 0754 7034 '))
# Accept references in unstructured format
self.assertTrue(is_valid_structured_reference_iso(' RF18539007547034'))
# Validates with zero's added in front
self.assertTrue(is_valid_structured_reference_iso('RF18000000000539007547034'))
# Does not validate invalid structured format
self.assertFalse(is_valid_structured_reference_iso('18539007547034RF'))
# Does not validate invalid checksum
self.assertFalse(is_valid_structured_reference_iso('RF17539007547034'))
# Validates the entire string
self.assertFalse(is_valid_structured_reference_be('RF18539007547034-OTHER-RANDOM-STUFF'))
def test_structured_reference_be(self):
# Accepts references in both structured formats
self.assertTrue(is_valid_structured_reference_be(' +++020/3430/57642+++'))
self.assertTrue(is_valid_structured_reference_be('***020/3430/57642*** '))
# Accept references in unstructured format
self.assertTrue(is_valid_structured_reference_be(' 020343057642'))
self.assertTrue(is_valid_structured_reference_be('020/3430/57642'))
# Validates edge case where result of % 97 = 0
self.assertTrue(is_valid_structured_reference_be('020343053497'))
# Does not validate invalid structured format
self.assertFalse(is_valid_structured_reference_be('***02/03430/57642***'))
# Does not validate invalid checksum
self.assertFalse(is_valid_structured_reference_be('020343057641'))
# Validates the entire string
self.assertFalse(is_valid_structured_reference_be('020343053497-OTHER-RANDOM-STUFF'))
def test_structured_reference_fi(self):
# Accepts references in structured format
self.assertTrue(is_valid_structured_reference_fi('2023 0000 98'))
# Accept references in unstructured format
self.assertTrue(is_valid_structured_reference_fi('2023000098'))
# Validates with zero's added in front
self.assertTrue(is_valid_structured_reference_fi('00000000002023000098'))
# Does not validate invalid structured format
self.assertFalse(is_valid_structured_reference_fi('2023/0000/98'))
# Does not validate invalid length
self.assertFalse(is_valid_structured_reference_fi('000000000002023000098'))
# Does not validate invalid checksum
self.assertFalse(is_valid_structured_reference_fi('2023000095'))
# Validates the entire string
self.assertFalse(is_valid_structured_reference_fi('2023000098-OTHER-RANDOM-STUFF'))
def test_structured_reference_no_se(self):
# Accepts references in structured format
self.assertTrue(is_valid_structured_reference_no_se('1234 5678 97'))
# Accept references in unstructured format
self.assertTrue(is_valid_structured_reference_no_se('1234567897'))
# Validates with zero's added in front
self.assertTrue(is_valid_structured_reference_no_se('000001234567897'))
# Does not validate invalid structured format
self.assertFalse(is_valid_structured_reference_no_se('1234/5678/97'))
# Does not validate invalid checksum
self.assertFalse(is_valid_structured_reference_no_se('1234567898'))
# Validates the entire string
self.assertFalse(is_valid_structured_reference_no_se('1234567897-OTHER-RANDOM-STUFF'))
def test_structured_reference_si(self):
# Valid structured references (must have 2 hyphens and valid check digit)
self.assertTrue(is_valid_structured_reference_si("SI01 25-20-85"))
self.assertTrue(is_valid_structured_reference_si(" SI01 25 - 2 0-85 "))
self.assertTrue(is_valid_structured_reference_si("SI01 19-1235-84505"))
# Invalid check digit
self.assertFalse(is_valid_structured_reference_si("SI01 25-20-84"))
self.assertFalse(is_valid_structured_reference_si("SI01 19-1235-84504"))
# Format errors - wrong prefix
self.assertFalse(is_valid_structured_reference_si("SI02 25-20-85"))
self.assertFalse(is_valid_structured_reference_si("0519123584503"))
# Format errors - missing or wrong hyphens
self.assertFalse(is_valid_structured_reference_si("SI01 252085"))
self.assertFalse(is_valid_structured_reference_si("SI01 25-2085"))
self.assertFalse(is_valid_structured_reference_si("SI01 25--20-85"))
# Format errors - non-numeric or empty parts
self.assertFalse(is_valid_structured_reference_si("SI01 ab-cd-ef"))
self.assertFalse(is_valid_structured_reference_si("SI01 25-20-"))
self.assertFalse(is_valid_structured_reference_si("SI01"))
def test_structured_reference_nl(self):
# Accepts 7 digits
self.assertTrue(is_valid_structured_reference_nl('1234567'))
# Accepts 9 digits
self.assertTrue(is_valid_structured_reference_nl('271234567'))
# Accepts 14 digits
self.assertTrue(is_valid_structured_reference_nl('42234567890123'))
# Accepts 16 digits
self.assertTrue(is_valid_structured_reference_nl('5000056789012345'))
# Accepts particular chase (check = 11 or 10)
self.assertTrue(is_valid_structured_reference_nl('0123456788')) # Check of 123456788 == 11 then check = 0
self.assertTrue(is_valid_structured_reference_nl('123456789107')) # Check of 23456789107 == 10 then check = 1
# Accepts spaces
self.assertTrue(is_valid_structured_reference_nl('5 000 0567 8901 2345'))
self.assertTrue(is_valid_structured_reference_nl(' 5000056789012345 '))
# Check the length
self.assertFalse(is_valid_structured_reference_nl('123456'))
self.assertFalse(is_valid_structured_reference_nl('12345678'))
self.assertFalse(is_valid_structured_reference_nl('123456789012345'))
self.assertFalse(is_valid_structured_reference_nl('12345678901234567'))
# Check the checksum
self.assertFalse(is_valid_structured_reference_nl('4000056789012345'))
# Check the entire string
self.assertFalse(is_valid_structured_reference_nl('5000056789012345-OTHER-RANDOM-STUFF'))
def test_structured_reference(self):
# Accepts references in structured format
self.assertTrue(is_valid_structured_reference(' RF18 5390 0754 7034 ')) # ISO
self.assertTrue(is_valid_structured_reference(' +++020/3430/57642+++')) # BE
self.assertTrue(is_valid_structured_reference('***020/3430/57642*** ')) # BE
self.assertTrue(is_valid_structured_reference('2023 0000 98')) # FI
self.assertTrue(is_valid_structured_reference('1234 5678 97')) # NO-SE
self.assertTrue(is_valid_structured_reference("SI01 25-20-85")) # SI
self.assertTrue(is_valid_structured_reference('5000056789012345')) # NL
# Accept references in unstructured format
self.assertTrue(is_valid_structured_reference(' RF18539007547034')) # ISO
self.assertTrue(is_valid_structured_reference(' 020343057642')) # BE
self.assertTrue(is_valid_structured_reference('2023000098')) # FI
self.assertTrue(is_valid_structured_reference('1234567897')) # NO-SE
self.assertTrue(is_valid_structured_reference(" SI01 25 - 2 0-85 ")) # SI
self.assertTrue(is_valid_structured_reference('5 000 0567 8901 2345')) # NL
# Validates with zero's added in front
self.assertTrue(is_valid_structured_reference('RF18000000000539007547034')) # ISO
self.assertTrue(is_valid_structured_reference('00000000002023000098')) # FI
self.assertTrue(is_valid_structured_reference('000001234567897')) # NO-SE
# Does not validate invalid structured format
self.assertFalse(is_valid_structured_reference('18539007547034RF')) # ISO
self.assertFalse(is_valid_structured_reference('***02/03430/57642***')) # BE
self.assertFalse(is_valid_structured_reference('2023/0000/98')) # FI
self.assertFalse(is_valid_structured_reference('1234/5678/97')) # NO-SE
self.assertFalse(is_valid_structured_reference("0519123584503")) # SI
self.assertFalse(is_valid_structured_reference('(5)000 0567 8901 2345')) # NL
# Does not validate invalid checksum
self.assertFalse(is_valid_structured_reference('RF17539007547034')) # ISO
self.assertFalse(is_valid_structured_reference('020343057641')) # BE
self.assertFalse(is_valid_structured_reference('2023000095')) # FI
self.assertFalse(is_valid_structured_reference('1234567898')) # NO-SE
self.assertFalse(is_valid_structured_reference("SI01 19-1235-84504")) # SI
self.assertFalse(is_valid_structured_reference('6000056789012345')) # NL
def test_structured_reference_for_country(self):
# Valid structured references for supported countries
self.assertTrue(is_valid_structured_reference_for_country('***020/3430/57642***', 'BE'))
self.assertTrue(is_valid_structured_reference_for_country('2023 0000 98', 'FI'))
self.assertTrue(is_valid_structured_reference_for_country('1234 5678 97', 'NO'))
self.assertTrue(is_valid_structured_reference_for_country('1234 5678 97', 'SE'))
self.assertTrue(is_valid_structured_reference_for_country('5000056789012345', 'NL'))
self.assertTrue(is_valid_structured_reference_for_country('SI01 25-20-85', 'SI'))
# Fallback to ISO 11649 for unsupported countries
self.assertTrue(is_valid_structured_reference_for_country('RF18 5390 0754 7034 ', 'FR'))
# Returns False for invalid references
self.assertFalse(is_valid_structured_reference_for_country(''))

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@ from odoo.tests import tagged
class TaxReportTest(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.test_country_1 = cls.env['res.country'].create({
'name': "The Old World",
'code': 'YY',
@ -71,11 +71,11 @@ class TaxReportTest(AccountTestInvoicingCommon):
def _get_tax_tags(self, country, tag_name=None, active_test=True):
domain = [('country_id', '=', country.id), ('applicability', '=', 'taxes')]
if tag_name:
domain.append(('name', 'like', '_' + tag_name))
domain.append(('name', '=', tag_name))
return self.env['account.account.tag'].with_context(active_test=active_test).search(domain)
def test_create_shared_tags(self):
self.assertEqual(len(self._get_tax_tags(self.test_country_1, tag_name='01')), 2, "tax_tags expressions created for reports within the same countries using the same formula should create a single pair of tags.")
self.assertEqual(len(self._get_tax_tags(self.test_country_1, tag_name='01')), 1, "tax_tags expressions created for reports within the same countries using the same formula should create a single tag.")
def test_add_expression(self):
""" Adding a tax_tags expression creates new tags.
@ -84,7 +84,7 @@ class TaxReportTest(AccountTestInvoicingCommon):
self._create_basic_tax_report_line(self.tax_report_1, "new tax_tags line", 'tournicoti')
tags_after = self._get_tax_tags(self.test_country_1)
self.assertEqual(len(tags_after), len(tags_before) + 2, "Two tags should have been created, +tournicoti and -tournicoti.")
self.assertEqual(len(tags_after), len(tags_before) + 1)
def test_write_single_line_tagname_not_shared(self):
""" Writing on the formula of a tax_tags expression should overwrite the name of the existing tags if they are not used in other formulas.
@ -109,7 +109,7 @@ class TaxReportTest(AccountTestInvoicingCommon):
new_tags = self.tax_report_line_1_1.expression_ids._get_matching_tags()
self.assertEqual(self._get_tax_tags(self.test_country_1, tag_name=original_tag_name), original_tags, "The original tags should be unchanged")
self.assertEqual(len(self._get_tax_tags(self.test_country_1)), len(start_tags) + 2, "A + and - tag should have been created")
self.assertEqual(len(self._get_tax_tags(self.test_country_1)), len(start_tags) + 1, "A tag should have been created")
self.assertNotEqual(original_tags, new_tags, "New tags should have been assigned to the expression")
def test_write_multi_no_change(self):
@ -130,7 +130,7 @@ class TaxReportTest(AccountTestInvoicingCommon):
lines.expression_ids.write({'formula': 'crabe'})
tags_after = self._get_tax_tags(self.test_country_1)
self.assertEqual(len(tags_before) + 2, len(tags_after), "Only two distinct tags should have been created.")
self.assertEqual(len(tags_before) + 1, len(tags_after), "Only tag should have been created.")
line_1_1_tags = self.tax_report_line_1_1.expression_ids._get_matching_tags()
line_2_2_tags = self.tax_report_line_2_2.expression_ids._get_matching_tags()
@ -155,7 +155,7 @@ class TaxReportTest(AccountTestInvoicingCommon):
country_1_tags_after_change = self._get_tax_tags(self.test_country_1)
self.assertEqual(country_1_tags_after_change, country_1_tags_after_copy, "Modifying the country should not have changed the tags in the original country.")
self.assertEqual(len(country_2_tags_after_change), len(country_2_tags_before_change) + 2 * len(copied_report_1.line_ids), "Modifying the country should have created a new + and - tag in the new country for each tax_tags expression of the report.")
self.assertEqual(len(country_2_tags_after_change), len(country_2_tags_before_change) + len(copied_report_1.line_ids), "Modifying the country should have created a tag in the new country for each tax_tags expression of the report.")
for original, copy in zip(self.tax_report_1.line_ids, copied_report_1.line_ids):
original_tags = original.expression_ids._get_matching_tags()
@ -185,7 +185,11 @@ class TaxReportTest(AccountTestInvoicingCommon):
"""
tag_name = "55b"
tax_report_line = self._create_basic_tax_report_line(self.tax_report_1, "Line 55 bis", tag_name)
test_tag = tax_report_line.expression_ids._get_matching_tags("+")
test_tag = tax_report_line.expression_ids._get_matching_tags()
self.env['account.tax.group'].create({
'name': 'Tax group',
'country_id': self.tax_report_1.country_id.id,
})
test_tax = self.env['account.tax'].create({
'name': "Test tax",
'amount_type': 'percent',
@ -222,7 +226,6 @@ class TaxReportTest(AccountTestInvoicingCommon):
tax_report_line.unlink()
tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False)
# only the +tag_name should be kept (and archived), -tag_name should be unlinked
self.assertEqual(tags_after.mapped('tax_negate'), [False], "Unlinking a tax_tags expression should keep the tag if it was used on move lines, and unlink it otherwise.")
self.assertEqual(tags_after.mapped('active'), [False], "Unlinking a tax_tags expression should archive the tag if it was used on move lines, and unlink it otherwise.")
self.assertEqual(len(test_tax.invoice_repartition_line_ids.tag_ids), 0, "After a tag is archived it shouldn't be on tax repartition lines.")
@ -247,12 +250,18 @@ class TaxReportTest(AccountTestInvoicingCommon):
"""
tag_name = self.tax_report_line_1_55.expression_ids.formula
tags_before = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False)
tags_before[0].unlink() # we unlink one and archive the other, doesn't matter which one
tags_before[1].active = False
tags_before.unlink()
self._create_basic_tax_report_line(self.tax_report_1, "Line 55 bis", tag_name)
tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False)
self.assertEqual(len(tags_after), 2, "When creating a tax report line with an archived tag and it's complement doesn't exist, it should be re-created.")
self.assertEqual(tags_after.mapped('name'), ['+' + tag_name, '-' + tag_name], "After creating a tax report line with an archived tag and when its complement doesn't exist, both a negative and a positive tag should be created.")
self.assertEqual(len(tags_after), 1)
self.assertEqual(tags_after.mapped('name'), [tag_name])
tags_before.active = False
self._create_basic_tax_report_line(self.tax_report_1, "Line 55 bis", tag_name)
tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False)
self.assertEqual(len(tags_after), 1)
self.assertEqual(tags_after.mapped('name'), [tag_name])
def test_change_engine_without_formula(self):
aggregation_line = self.env['account.report.line'].create({
@ -262,7 +271,7 @@ class TaxReportTest(AccountTestInvoicingCommon):
Command.create({
'label': 'balance',
'engine': 'aggregation',
'formula': 'Dudu',
'formula': 'Dudu.balance',
}),
],
})
@ -272,9 +281,9 @@ class TaxReportTest(AccountTestInvoicingCommon):
aggregation_line.expression_ids.engine = 'tax_tags'
tags_after = self._get_tax_tags(self.test_country_1, tag_name='Dudu')
self.assertEqual(len(tags_after), 2, "Changing the engine should have created tags")
self.assertEqual(tags_after.mapped('name'), ['-Dudu', '+Dudu'])
tags_after = self._get_tax_tags(self.test_country_1, tag_name='Dudu.balance')
self.assertEqual(len(tags_after), 1, "Changing the engine should have created a tag")
self.assertEqual(tags_after.mapped('name'), ['Dudu.balance'])
def test_change_engine_shared_tags(self):
aggregation_line = self.env['account.report.line'].create({
@ -284,13 +293,13 @@ class TaxReportTest(AccountTestInvoicingCommon):
Command.create({
'label': 'balance',
'engine': 'aggregation',
'formula': 'Dudu',
'formula': 'Dudu.balance',
}),
],
})
tags_before = self._get_tax_tags(self.test_country_1, tag_name='01')
self.assertEqual(len(tags_before), 2, "The tags should already exist because of another expression")
self.assertEqual(len(tags_before), 1, "The tag should already exist because of another expression")
aggregation_line.expression_ids.write({'engine': 'tax_tags', 'formula': '01'})
@ -309,6 +318,6 @@ class TaxReportTest(AccountTestInvoicingCommon):
})
tags_after = self._get_tax_tags(self.test_country_1, tag_name='Buny')
self.assertEqual(len(tags_after), 2, "Changing the formula should have renamed the tags")
self.assertEqual(tags_after.mapped('name'), ['-Buny', '+Buny'])
self.assertEqual(len(tags_after), 1, "Changing the formula should have renamed the tag")
self.assertEqual(tags_after.mapped('name'), ['Buny'])
self.assertEqual(tags_after, tags_to_rename, "Changing the formula should have renamed the tags")

View file

@ -0,0 +1,175 @@
from odoo.tests import tagged
from odoo.addons.account.tests.common import TestTaxCommon
@tagged('post_install', '-at_install')
class TestTaxesBaseLinesTaxDetails(TestTaxCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.company.tax_calculation_rounding_method = 'round_globally'
def test_dispatch_delta_on_base_lines(self):
""" Make sure that the base line delta is dispatched evenly on base lines.
Needed for BIS3 rule PEPPOL-EN16931-R120.
"""
tax_21 = self.percent_tax(21.0)
document = self.populate_document(self.init_document(
lines=[
{'price_unit': 10.04, 'discount': 10, 'tax_ids': tax_21},
] + [
{'price_unit': 1.04, 'discount': 10, 'tax_ids': tax_21},
] * 10,
))
line_1_expected_values = {
'total_excluded': 9.04,
'total_excluded_currency': 9.04,
'total_included': 10.94,
'total_included_currency': 10.94,
'delta_total_excluded': 0.0,
'delta_total_excluded_currency': 0.0,
'manual_total_excluded': None,
'manual_total_excluded_currency': None,
'manual_tax_amounts': None,
'taxes_data': [
{
'tax_id': tax_21.id,
'tax_amount': 1.8699999999999999,
'tax_amount_currency': 1.8699999999999999,
'base_amount': 9.01,
'base_amount_currency': 9.01,
}
],
}
line_2_expected_values = {
'total_excluded': 0.94,
'total_excluded_currency': 0.94,
'total_included': 1.14,
'total_included_currency': 1.14,
'delta_total_excluded': 0.0,
'delta_total_excluded_currency': 0.0,
'manual_total_excluded': None,
'manual_total_excluded_currency': None,
'manual_tax_amounts': None,
'taxes_data': [
{
'tax_id': tax_21.id,
'tax_amount': 0.2,
'tax_amount_currency': 0.2,
'base_amount': 0.94,
'base_amount_currency': 0.94,
}
],
}
expected_values = {
'base_lines_tax_details': [
{
**line_1_expected_values,
'delta_total_excluded': -0.03,
'delta_total_excluded_currency': -0.03,
},
{
**line_2_expected_values,
'delta_total_excluded': -0.01,
'delta_total_excluded_currency': -0.01,
'taxes_data': [
{
'tax_id': tax_21.id,
'tax_amount': 0.19,
'tax_amount_currency': 0.19,
'base_amount': 0.9299999999999999,
'base_amount_currency': 0.9299999999999999,
}
],
},
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
line_2_expected_values,
]
}
self.assert_base_lines_tax_details(document, expected_values)
self._run_js_tests()
def test_dispatch_delta_on_net_zero_tax(self):
"""Check that the base line delta still is dispatched if net tax is zero."""
def get_expected_values(base_values, tax):
expected_tax_total = sum(values[2] for values in base_expected_values)
self.assertTrue(
tax.company_id.currency_id.is_zero(expected_tax_total),
"Expected taxes should add up to zero.",
)
return {
'base_lines_tax_details': [{
'delta_total_excluded': 0.0,
'delta_total_excluded_currency': 0.0,
'manual_tax_amounts': None,
'manual_total_excluded': None,
'manual_total_excluded_currency': None,
'taxes_data': [{
'base_amount': total_excluded,
'base_amount_currency': total_excluded,
'tax_amount': tax_amount,
'tax_amount_currency': tax_amount,
'tax_id': tax.id,
}],
'total_excluded': total_excluded,
'total_excluded_currency': total_excluded,
'total_included': total_included,
'total_included_currency': total_included,
} for total_excluded, total_included, tax_amount in base_values],
}
with self.subTest("19.99% tax, 100% global discount"):
tax_19_99 = self.percent_tax(19.99)
lines_vals = [{
'price_unit': price,
'quantity': 1,
'tax_ids': tax_19_99,
} for price in (19.99, 19.99, -39.98)]
document = self.populate_document(self.init_document(lines_vals))
base_expected_values = [
# (total_excluded, total_included, tax_amount),
(19.99, 23.99, 4.0),
(19.99, 23.99, 4.0),
(-39.98, -47.97, -8.0),
]
expected_values = get_expected_values(base_expected_values, tax_19_99)
self.assert_base_lines_tax_details(document, expected_values)
self._run_js_tests()
with self.subTest("7% tax, 30% line discount, 100% global discount"):
tax_7 = self.percent_tax(7.0)
lines_vals = [{
'price_unit': price,
'quantity': 4,
'discount': 30,
'tax_ids': tax_7,
} for price in (1068, 46, 46, 298, 5)]
lines_vals.append({'price_unit': -4096.4, 'tax_ids': tax_7}) # 100% discount
document = self.populate_document(self.init_document(lines_vals))
base_expected_values = [
# (total_excluded, total_included, tax_amount),
(2990.4, 3199.73, 209.33),
(128.8, 137.82000000000002, 9.02),
(128.8, 137.82000000000002, 9.02),
(834.4, 892.81, 58.41),
(14.0, 14.98, 0.98),
(-4096.4, -4383.15, -286.76),
]
expected_values = get_expected_values(base_expected_values, tax_7)
self.assert_base_lines_tax_details(document, expected_values)
self._run_js_tests()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,360 @@
from odoo.addons.account.tests.common import TestTaxCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestTaxesDispatchingBaseLines(TestTaxCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.currency = cls.env.company.currency_id
cls.foreign_currency = cls.setup_other_currency('EUR')
def test_dispatch_return_of_merchandise_lines(self):
self.env.company.tax_calculation_rounding_method = 'round_globally'
AccountTax = self.env['account.tax']
tax1 = self.fixed_tax(1, include_base_amount=True)
tax2 = self.percent_tax(21)
taxes = tax1 + tax2
document_params = self.init_document(
lines=[
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': 10, 'tax_ids': taxes},
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': 10, 'tax_ids': taxes},
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': -12, 'tax_ids': taxes},
],
currency=self.foreign_currency,
rate=0.5,
)
expected_values = {
'same_tax_base': True,
'currency_id': self.foreign_currency.id,
'company_currency_id': self.currency.id,
'base_amount_currency': 134.32,
'base_amount': 268.64,
'tax_amount_currency': 37.89,
'tax_amount': 75.77,
'total_amount_currency': 172.21,
'total_amount': 344.41,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 134.32,
'base_amount': 268.64,
'tax_amount_currency': 37.89,
'tax_amount': 75.77,
'tax_groups': [
{
'id': taxes.tax_group_id.id,
'base_amount_currency': 134.32,
'base_amount': 268.64,
'tax_amount_currency': 37.89,
'tax_amount': 75.77,
'display_base_amount_currency': 134.32,
'display_base_amount': 268.64,
},
],
},
],
}
document = self.populate_document(document_params)
base_lines = document['lines']
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
# Dispatch the return of product on the others base lines.
self.assertEqual(len(base_lines), 3)
base_lines = AccountTax._dispatch_return_of_merchandise_lines(document['lines'], self.env.company)
AccountTax._squash_return_of_merchandise_lines(base_lines, self.env.company)
self.assertEqual(len(base_lines), 2)
self.assertEqual(base_lines[0]['quantity'], 0)
self.assertEqual(base_lines[1]['quantity'], 8)
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
def test_dispatch_return_of_merchandise_lines_no_match(self):
self.env.company.tax_calculation_rounding_method = 'round_globally'
AccountTax = self.env['account.tax']
tax = self.percent_tax(21)
document_params = self.init_document(
lines=[
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': 10, 'tax_ids': tax},
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': -2, 'tax_ids': []},
],
)
expected_values = {
'same_tax_base': False,
'currency_id': self.currency.id,
'base_amount_currency': 134.32,
'tax_amount_currency': 35.26,
'total_amount_currency': 169.58,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 134.32,
'tax_amount_currency': 35.26,
'tax_groups': [
{
'id': tax.tax_group_id.id,
'base_amount_currency': 167.9,
'tax_amount_currency': 35.26,
'display_base_amount_currency': 167.9,
},
],
},
],
}
document = self.populate_document(document_params)
base_lines = document['lines']
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
# Dispatch the return of product on the others base lines.
# The dispatching should fail so no changes.
self.assertEqual(len(base_lines), 2)
base_lines = AccountTax._dispatch_return_of_merchandise_lines(document['lines'], self.env.company)
AccountTax._squash_return_of_merchandise_lines(base_lines, self.env.company)
self.assertEqual(len(base_lines), 2)
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
def test_dispatch_global_discount_lines(self):
self.env.company.tax_calculation_rounding_method = 'round_globally'
AccountTax = self.env['account.tax']
tax1 = self.fixed_tax(1, include_base_amount=True)
tax2 = self.percent_tax(21)
taxes = tax1 + tax2
document_params = self.init_document(
lines=[
{'product_id': self.product_a, 'price_unit': 33.58, 'quantity': 10, 'tax_ids': taxes},
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': 10, 'tax_ids': taxes},
],
currency=self.foreign_currency,
rate=0.5,
)
expected_values = {
'same_tax_base': True,
'currency_id': self.foreign_currency.id,
'company_currency_id': self.currency.id,
'base_amount_currency': 503.7,
'base_amount': 1007.4,
'tax_amount_currency': 129.98,
'tax_amount': 259.95,
'total_amount_currency': 633.68,
'total_amount': 1267.35,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 503.7,
'base_amount': 1007.4,
'tax_amount_currency': 129.98,
'tax_amount': 259.95,
'tax_groups': [
{
'id': taxes.tax_group_id.id,
'base_amount_currency': 503.7,
'base_amount': 1007.4,
'tax_amount_currency': 129.98,
'tax_amount': 259.95,
'display_base_amount_currency': 503.7,
'display_base_amount': 1007.4,
},
],
},
],
}
document = self.populate_document(document_params)
base_lines = document['lines']
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
# Global discount 20%.
discount_base_lines = AccountTax._prepare_global_discount_lines(base_lines, self.env.company, 'percent', 20.0)
base_lines += discount_base_lines
AccountTax._add_tax_details_in_base_lines(base_lines, self.env.company)
AccountTax._round_base_lines_tax_details(base_lines, self.env.company)
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
expected_values = {
'same_tax_base': True,
'currency_id': self.foreign_currency.id,
'company_currency_id': self.currency.id,
'base_amount_currency': 402.96,
'base_amount': 805.92,
'tax_amount_currency': 108.82,
'tax_amount': 217.64,
'total_amount_currency': 511.78,
'total_amount': 1023.56,
'subtotals': [
{
'name': "Untaxed Amount",
'base_amount_currency': 402.96,
'base_amount': 805.92,
'tax_amount_currency': 108.82,
'tax_amount': 217.64,
'tax_groups': [
{
'id': taxes.tax_group_id.id,
'base_amount_currency': 402.96,
'base_amount': 805.92,
'tax_amount_currency': 108.82,
'tax_amount': 217.64,
'display_base_amount_currency': 402.96,
'display_base_amount': 805.92,
},
],
},
],
}
self._assert_tax_totals_summary(tax_totals, expected_values)
# Dispatch the global discount on the others base lines.
self.assertEqual(len(base_lines), 3)
base_lines[-1]['special_type'] = 'global_discount'
base_lines = AccountTax._dispatch_global_discount_lines(base_lines, self.env.company)
AccountTax._squash_global_discount_lines(base_lines, self.env.company)
self.assertEqual(len(base_lines), 2)
tax_totals = AccountTax._get_tax_totals_summary(base_lines, document['currency'], self.env.company)
self._assert_tax_totals_summary(tax_totals, expected_values)
def test_dispatch_global_discount_lines_no_match(self):
self.env.company.tax_calculation_rounding_method = 'round_globally'
AccountTax = self.env['account.tax']
tax = self.percent_tax(21)
document_params = self.init_document(
lines=[
{'product_id': self.product_a, 'price_unit': 33.58, 'quantity': 10, 'tax_ids': tax},
{'product_id': self.product_a, 'price_unit': 16.79, 'quantity': 10, 'tax_ids': tax},
{'product_id': self.product_a, 'price_unit': -50.0, 'quantity': 1, 'tax_ids': [], 'special_type': 'global_discount'},
],
)
document = self.populate_document(document_params)
base_lines = document['lines']
# Should fail to dispatch the global discount on the others base lines.
self.assertEqual(len(base_lines), 3)
base_lines = AccountTax._dispatch_global_discount_lines(base_lines, self.env.company)
AccountTax._squash_global_discount_lines(base_lines, self.env.company)
self.assertEqual(len(base_lines), 3)
def test_dispatch_taxes_into_new_base_lines(self):
def assert_tax_totals_summary_after_dispatching(document, exclude_function, expected_values):
new_base_lines = AccountTax._dispatch_taxes_into_new_base_lines(
base_lines=document['lines'],
company=self.env.company,
exclude_function=exclude_function,
)
extra_base_lines = AccountTax._turn_removed_taxes_into_new_base_lines(new_base_lines, self.env.company)
self.assert_tax_totals_summary(
document={
**document,
'lines': new_base_lines + extra_base_lines,
},
expected_values=expected_values,
soft_checking=True,
)
AccountTax = self.env['account.tax']
self.env.company.tax_calculation_rounding_method = 'round_globally'
tax1 = self.fixed_tax(1, include_base_amount=True)
tax2 = self.fixed_tax(5)
tax3 = self.percent_tax(21)
taxes = tax1 + tax2 + tax3
document_params = self.init_document(
lines=[
{'price_unit': 16.79, 'tax_ids': taxes},
{'price_unit': 16.79, 'tax_ids': taxes},
],
currency=self.foreign_currency,
rate=0.5,
)
document = self.populate_document(document_params)
expected_values = {
'base_amount_currency': 33.58,
'tax_amount_currency': 19.47,
'total_amount_currency': 53.05,
}
self.assert_tax_totals_summary(document, expected_values, soft_checking=True)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax1,
expected_values={
**expected_values,
'base_amount_currency': 35.58,
'tax_amount_currency': 17.47,
},
)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax2,
expected_values={
**expected_values,
'base_amount_currency': 43.58,
'tax_amount_currency': 9.47,
},
)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax3,
expected_values={
**expected_values,
'base_amount_currency': 41.05,
'tax_amount_currency': 12.0,
},
)
taxes.price_include_override = 'tax_included'
document_params = self.init_document(
lines=[
{'price_unit': 21.53, 'tax_ids': taxes},
{'price_unit': 21.53, 'tax_ids': taxes},
],
currency=self.foreign_currency,
rate=0.5,
)
document = self.populate_document(document_params)
expected_values = {
'base_amount_currency': 25.32,
'tax_amount_currency': 17.74,
'total_amount_currency': 43.06,
}
self.assert_tax_totals_summary(document, expected_values, soft_checking=True)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax1,
expected_values={
**expected_values,
'base_amount_currency': 27.32,
'tax_amount_currency': 15.74,
},
)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax2,
expected_values={
**expected_values,
'base_amount_currency': 35.32,
'tax_amount_currency': 7.74,
},
)
assert_tax_totals_summary_after_dispatching(
document=document,
exclude_function=lambda base_line, tax_data: tax_data['tax'] == tax3,
expected_values={
**expected_values,
'base_amount_currency': 31.06,
'tax_amount_currency': 12.0,
},
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,87 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class AccountingTestTemplConsistency(TransactionCase):
'''Test the templates consistency between some objects like account.account when account.account.template.
'''
def get_model_fields(self, model, extra_domain=None):
# Retrieve fields to compare
domain = [
('model', '=', model),
('state', '=', 'base'),
('related', '=', False),
('compute', '=', False),
('store', '=', True),
]
if extra_domain:
domain += extra_domain
return self.env['ir.model.fields'].search(domain)
def check_fields_consistency(self, model_from, model_to, exceptions=None):
'''Check the consistency of fields from one model to another by comparing if all fields
in the model_from are present in the model_to.
:param model_from: The model to compare.
:param model_to: The compared model.
:param exceptions: Not copied model's fields.
'''
extra_domain = [('name', 'not in', exceptions)] if exceptions else []
from_fields = self.get_model_fields(model_from, extra_domain=extra_domain).filtered_domain([('modules', '=', 'account')])
to_fields_set = set([f.name for f in self.get_model_fields(model_to)])
for field in from_fields:
assert field.name in to_fields_set,\
'Missing field "%s" from "%s" in model "%s".' % (field.name, model_from, model_to)
def test_account_account_fields(self):
'''Test fields consistency for ('account.account', 'account.account.template')
'''
self.check_fields_consistency(
'account.account.template', 'account.account', exceptions=['chart_template_id', 'nocreate'])
self.check_fields_consistency(
'account.account', 'account.account.template', exceptions=['company_id', 'deprecated', 'opening_debit', 'opening_credit', 'allowed_journal_ids', 'group_id', 'root_id', 'is_off_balance', 'non_trade', 'include_initial_balance', 'internal_group'])
def test_account_tax_fields(self):
'''Test fields consistency for ('account.tax', 'account.tax.template')
'''
self.check_fields_consistency('account.tax.template', 'account.tax', exceptions=['chart_template_id'])
self.check_fields_consistency('account.tax', 'account.tax.template', exceptions=['company_id', 'country_id', 'real_amount'])
self.check_fields_consistency('account.tax.repartition.line.template', 'account.tax.repartition.line', exceptions=['plus_report_expression_ids', 'minus_report_expression_ids'])
self.check_fields_consistency('account.tax.repartition.line', 'account.tax.repartition.line.template', exceptions=['tag_ids', 'country_id', 'company_id', 'sequence'])
def test_fiscal_position_fields(self):
'''Test fields consistency for ('account.fiscal.position', 'account.fiscal.position.template')
'''
#main
self.check_fields_consistency('account.fiscal.position.template', 'account.fiscal.position', exceptions=['chart_template_id'])
self.check_fields_consistency('account.fiscal.position', 'account.fiscal.position.template', exceptions=['active', 'company_id', 'states_count', 'foreign_vat'])
#taxes
self.check_fields_consistency('account.fiscal.position.tax.template', 'account.fiscal.position.tax')
self.check_fields_consistency('account.fiscal.position.tax', 'account.fiscal.position.tax.template')
#accounts
self.check_fields_consistency('account.fiscal.position.account.template', 'account.fiscal.position.account')
self.check_fields_consistency('account.fiscal.position.account', 'account.fiscal.position.account.template')
def test_reconcile_model_fields(self):
'''Test fields consistency for ('account.reconcile.model', 'account.reconcile.model.template')
'''
self.check_fields_consistency('account.reconcile.model.template', 'account.reconcile.model', exceptions=['chart_template_id'])
# exclude fields from inherited 'mail.thread'
mail_thread_fields = [field.name for field in self.get_model_fields('mail.thread')]
self.check_fields_consistency(
'account.reconcile.model',
'account.reconcile.model.template',
exceptions=mail_thread_fields + ['active', 'company_id', 'past_months_limit', 'partner_mapping_line_ids'],
)
# lines
self.check_fields_consistency('account.reconcile.model.line.template', 'account.reconcile.model.line', exceptions=['chart_template_id'])
self.check_fields_consistency('account.reconcile.model.line', 'account.reconcile.model.line.template', exceptions=['company_id', 'journal_id', 'analytic_distribution', 'amount'])
def test_account_group_fields(self):
'''Test fields consistency for ('account.group', 'account.group.template')
'''
self.check_fields_consistency('account.group', 'account.group.template', exceptions=['company_id', 'parent_path'])
self.check_fields_consistency('account.group.template', 'account.group', exceptions=['chart_template_id'])

View file

@ -1,19 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import Command, fields
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
@odoo.tests.tagged('post_install_l10n', 'post_install', '-at_install')
class TestUi(AccountTestInvoicingCommon, odoo.tests.HttpCase):
class TestUi(AccountTestInvoicingHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
all_moves = cls.env['account.move'].search([('move_type', '!=', 'entry')])
all_moves = all_moves.filtered(lambda m: not m.inalterable_hash and m.state in ('posted', 'cancel'))
# This field is only present in account_accountant
if 'deferred_move_ids' in all_moves._fields:
all_moves = all_moves.filtered(lambda m: not m.deferred_move_ids)
all_moves.button_draft()
all_moves.with_context(force_delete=True).unlink()
@ -31,18 +35,34 @@ class TestUi(AccountTestInvoicingCommon, odoo.tests.HttpCase):
self.env.ref('base.user_admin').write({
'company_id': self.env.company.id,
'company_ids': [(4, self.env.company.id)],
'email': 'mitchell.admin@example.com',
})
self.env.company.write({
'country_id': None, # Also resets account_fiscal_country_id
'account_sale_tax_id': None,
'account_purchase_tax_id': None,
'external_report_layout_id': self.env.ref('web.external_layout_standard').id,
})
account_with_taxes = self.env['account.account'].search([('tax_ids', '!=', False), ('company_id', '=', self.env.company.id)])
account_with_taxes = self.env['account.account'].search([('tax_ids', '!=', False), ('company_ids', '=', self.env.company.id)])
account_with_taxes.write({
'tax_ids': [Command.clear()],
})
self.start_tour("/web", 'account_tour', login="admin")
# Remove all posted invoices to enable 'create first invoice' button
invoices = self.env['account.move'].search([('company_id', '=', self.env.company.id), ('move_type', '=', 'out_invoice')])
for invoice in invoices:
if invoice.state in ('cancel', 'posted'):
invoice.button_draft()
invoices.unlink()
# remove all entries in the miscellaneous journal to test the onboarding
self.env['account.move'].search([
('journal_id.type', '=', 'general'),
('state', '=', 'draft'),
]).unlink()
self.start_tour("/odoo", 'account_tour', login="admin")
def test_01_account_tax_groups_tour(self):
self.env.ref('base.user_admin').write({
@ -57,7 +77,7 @@ class TestUi(AccountTestInvoicingCommon, odoo.tests.HttpCase):
'name': 'Account Tax Group Product',
'standard_price': 600.0,
'list_price': 147.0,
'detailed_type': 'consu',
'type': 'consu',
})
new_tax = self.env['account.tax'].create({
'name': '10% Tour Tax',
@ -67,4 +87,32 @@ class TestUi(AccountTestInvoicingCommon, odoo.tests.HttpCase):
})
product.supplier_taxes_id = new_tax
self.start_tour("/web", 'account_tax_group', login="admin")
self.start_tour("/odoo", 'account_tax_group', login="admin")
def test_use_product_catalog_on_invoice(self):
self.product.write({
'is_favorite': True,
'default_code': '0',
})
self.start_tour("/odoo/customer-invoices/new", 'test_use_product_catalog_on_invoice', login="admin")
def test_deductible_amount_column(self):
self.assertFalse(self.env.user.has_group('account.group_partial_purchase_deductibility'))
partner = self.env['res.partner'].create({'name': "Test Partner", 'email': "test@test.odoo.com"})
move = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner.id,
'invoice_date': fields.Date.today(),
'line_ids': [Command.create({'name': "T-shirt", 'deductible_amount': 50.0})],
})
move.action_post()
self.assertTrue(self.env.user.has_group('account.group_partial_purchase_deductibility'))
self.start_tour("/odoo/vendor-bills/new", 'deductible_amount_column', login=self.env.user.login)
def test_add_section_from_product_catalog_on_invoice_tour(self):
self.product.write({'is_favorite': True})
self.start_tour(
'/odoo/customer-invoices/new',
'test_add_section_from_product_catalog_on_invoice',
login='admin',
)

View file

@ -11,13 +11,13 @@ import time
class TestTransferWizard(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.company = cls.company_data['company']
cls.receivable_account = cls.company_data['default_account_receivable']
cls.payable_account = cls.company_data['default_account_payable']
cls.accounts = cls.env['account.account'].search([('reconcile', '=', False), ('company_id', '=', cls.company.id)], limit=5)
cls.accounts = cls.env['account.account'].search([('reconcile', '=', False), ('company_ids', '=', cls.company.id)], limit=5)
cls.journal = cls.company_data['default_journal_misc']
# Set rate for base currency to 1
@ -193,8 +193,8 @@ class TestTransferWizard(AccountTestInvoicingCommon):
cls.move_2.action_post()
analytic_plan_1, analytic_plan_2 = cls.env['account.analytic.plan'].create([
{'name': 'Plan Test 1', 'company_id': False},
{'name': 'Plan Test 2', 'company_id': False},
{'name': 'Plan Test 1'},
{'name': 'Plan Test 2'},
])
cls.analytic_account_1, cls.analytic_account_2 = cls.env['account.analytic.account'].create([
{
@ -234,7 +234,7 @@ class TestTransferWizard(AccountTestInvoicingCommon):
}),
Command.create({
'account_id': self.payable_account.id,
'balance': -460,
'balance': -400,
}),
]
})
@ -242,16 +242,14 @@ class TestTransferWizard(AccountTestInvoicingCommon):
self.assertRecordValues(move_with_tax.line_ids, [
{'balance': 400, 'account_id': account_with_tax.id},
{'balance': -460, 'account_id': self.payable_account.id},
{'balance': 60, 'account_id': self.company_data['default_account_tax_purchase'].id},
{'balance': -400, 'account_id': self.payable_account.id},
])
# Open the transfer wizard
# We use a form to pass the context properly to the depends_context move_line_ids field
context = {'active_model': 'account.move.line', 'active_ids': move_with_tax.line_ids[0].ids}
context = {'active_model': 'account.move.line', 'active_ids': move_with_tax.line_ids[0].ids, 'default_action': 'change_period'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_period'
wizard_form.date = '2019-05-01'
wizard_form.journal_id = self.company_data['default_journal_misc']
wizard_form.expense_accrual_account = expense_accrual_account
@ -278,9 +276,8 @@ class TestTransferWizard(AccountTestInvoicingCommon):
active_move_lines = (self.move_1 + self.move_2).mapped('line_ids').filtered(lambda x: x.account_id.account_type in ('asset_receivable', 'liability_payable'))
# We use a form to pass the context properly to the depends_context move_line_ids field
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids}
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids, 'default_action': 'change_account'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_account'
wizard_form.destination_account_id = self.receivable_account
wizard_form.journal_id = self.journal
wizard = wizard_form.save()
@ -304,9 +301,8 @@ class TestTransferWizard(AccountTestInvoicingCommon):
active_move_lines = (self.move_1 + self.move_2).mapped('line_ids').filtered(lambda x: x.name in ('test1_3', 'test1_4', 'test1_5', 'test2_3', 'test2_4', 'test2_5', 'test2_6', 'test2_8'))
# We use a form to pass the context properly to the depends_context move_line_ids field
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids}
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids, 'default_action': 'change_account'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_account'
wizard_form.destination_account_id = self.accounts[4]
wizard_form.journal_id = self.journal
wizard = wizard_form.save()
@ -335,9 +331,8 @@ class TestTransferWizard(AccountTestInvoicingCommon):
active_move_lines = self.move_1.mapped('line_ids').filtered(lambda x: x.name in ('test1_6', 'test1_9'))
# We use a form to pass the context properly to the depends_context move_line_ids field
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids}
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids, 'default_action': 'change_account'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_account'
wizard_form.destination_account_id = self.test_currency_account
wizard_form.journal_id = self.journal
wizard = wizard_form.save()
@ -358,9 +353,8 @@ class TestTransferWizard(AccountTestInvoicingCommon):
active_move_lines = self.move_2.mapped('line_ids').filtered(lambda x: x.name in ('test2_9', 'test2_6', 'test2_8'))
# We use a form to pass the context properly to the depends_context move_line_ids field
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids}
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids, 'default_action': 'change_account'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_account'
wizard_form.destination_account_id = self.receivable_account
wizard_form.journal_id = self.journal
wizard = wizard_form.save()
@ -391,19 +385,32 @@ class TestTransferWizard(AccountTestInvoicingCommon):
'reconcile': True,
})
account_with_tax = self.env['account.account'].create({
'name': 'Auto Taxed',
'code': 'autotaxed',
'account_type': 'income',
'tax_ids': [Command.link(self.company_data['default_tax_sale'].id)],
})
# Create a move before the lock date
move = self.env['account.move'].create({
'journal_id': self.company_data['default_journal_sale'].id,
'date': '2019-01-01',
'line_ids': [
Command.create({'account_id': self.accounts[0].id, 'debit': 1000, }),
Command.create({'account_id': self.accounts[0].id, 'credit': 1000, }),
Command.create({'account_id': account_with_tax.id, 'debit': 1000}),
Command.create({'account_id': account_with_tax.id, 'credit': 1000}),
]
})
move.action_post()
# Set the lock date
move.company_id.write({'period_lock_date': '2019-02-28', 'fiscalyear_lock_date': '2019-02-28'})
# (Purchase Lock Date not tested)
move.company_id.write({
'hard_lock_date': '2019-02-28',
'fiscalyear_lock_date': '2019-02-28',
'sale_lock_date': '2019-02-28',
'tax_lock_date': '2019-02-28',
})
# Open the transfer wizard at a date after the lock date
wizard = self.env['account.automatic.entry.wizard'] \
@ -414,9 +421,9 @@ class TestTransferWizard(AccountTestInvoicingCommon):
'journal_id': self.company_data['default_journal_misc'].id,
})
# Check that the 'The date is being set prior to the user lock date' message appears.
# Check that the 'The date is being set prior to ...' message appears.
self.assertRecordValues(wizard, [{
'lock_date_message': 'The date is being set prior to the user lock date 02/28/2019. '
'lock_date_message': 'The date is being set prior to: Global Lock Date (02/28/2019), Hard Lock Date (02/28/2019), and Sales Lock Date (02/28/2019). '
'The Journal Entry will be accounted on 03/31/2019 upon posting.'
}])
@ -483,22 +490,20 @@ class TestTransferWizard(AccountTestInvoicingCommon):
'journal_id': self.company_data['default_journal_misc'].id,
'date': '2019-01-01',
'line_ids': [
Command.create({'account_id': self.accounts[2].id, 'currency_id': self.company.currency_id.id, 'amount_currency': 1000, 'debit': 1000, }),
Command.create({'account_id': self.receivable_account.id, 'currency_id': self.test_currency_1.id, 'amount_currency': 0, 'credit': 1000, }),
Command.create({'account_id': self.accounts[2].id, 'currency_id': self.company.currency_id.id, 'amount_currency': 1000, 'debit': 1000}),
Command.create({'account_id': self.receivable_account.id, 'currency_id': self.test_currency_1.id, 'amount_currency': 0, 'credit': 1000}),
]
})
move.action_post()
active_move_lines = move.line_ids.filtered(lambda line: line.account_id.id == self.receivable_account.id)
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids}
context = {'active_model': 'account.move.line', 'active_ids': active_move_lines.ids, 'default_action': 'change_account'}
with Form(self.env['account.automatic.entry.wizard'].with_context(context)) as wizard_form:
wizard_form.action = 'change_account'
wizard_form.destination_account_id = self.accounts[0]
wizard_form.journal_id = self.company_data['default_journal_misc']
automatic_entry_wizard = wizard_form.save()
transfer_move_id = automatic_entry_wizard.do_action()['res_id']
wizard = wizard_form.save()
transfer_move_id = wizard.do_action()['res_id']
transfer_move = self.env['account.move'].browse(transfer_move_id)
source_line = transfer_move.line_ids.filtered(lambda x: x.account_id == self.receivable_account)

View file

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
from datetime import date, timedelta
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestUnexpectedAmount(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context={
**cls.env.context,
'disable_abnormal_invoice_detection': False,
})
def _invoice_vals(self, date='2020-01-01', price_unit=100):
return {
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'invoice_date': date,
'line_ids': [
Command.create({
'name': 'product',
'price_unit': price_unit,
'tax_ids': [Command.clear()],
})
],
}
def test_higher_amount(self):
base = self.env['account.move'].create([self._invoice_vals(price_unit=100) for i in range(10)])
base.action_post()
bills = self.env['account.move'].create([
self._invoice_vals(price_unit=100),
self._invoice_vals(price_unit=200),
self._invoice_vals(price_unit=50),
self._invoice_vals(price_unit=10),
])
self.assertFalse(bills[0].abnormal_amount_warning, "The price of 100 is not deviant and thus shouldn't trigger a warning")
self.assertTrue(bills[1].abnormal_amount_warning, "The price of 200 is deviant and thus should trigger a warning")
self.assertTrue(bills[2].abnormal_amount_warning, "The price of 50 is deviant and thus should trigger a warning")
self.assertTrue(bills[3].abnormal_amount_warning, "The price of 10 is deviant and thus should trigger a warning")
# cleaning the bills context to have an unbiased env test for the wizard trigerring
bills = bills.with_context({
k: v for k, v in self.env.context.items() if k != 'disable_abnormal_invoice_detection'
})
wizard_thrown = bills[0].action_post()
self.assertFalse(wizard_thrown,
"Invoice is not deviant, no wizard should be thrown")
wizard_thrown = bills[1].action_post()
self.assertFalse(wizard_thrown,
"The amount is deviant but the context key isn't set, the wizard shouldn't be thrown")
wizard_thrown = bills[2].with_context(disable_abnormal_invoice_detection=True).action_post()
self.assertFalse(wizard_thrown,
"The amount is deviant and the context key is set to True, the wizard shouldn't be thrown")
wizard_thrown = bills[3].with_context(disable_abnormal_invoice_detection=False).action_post()
self.assertTrue(wizard_thrown,
"The amount is deviant and the context key is set to False, the wizard shouldn't be thrown")
def test_date_too_soon_year(self):
base = self.env['account.move'].create([
self._invoice_vals(date=f'{year}-01-01')
for year in range(2000, 2010)
])
base.action_post()
move = self.env['account.move'].create(self._invoice_vals(date='2010-01-01'))
self.assertFalse(move.abnormal_date_warning)
move = self.env['account.move'].create(self._invoice_vals(date='2009-06-01'))
self.assertTrue(move.abnormal_date_warning)
def test_date_too_soon_month(self):
# We get one invoice on the last day of the month from December 2019 to September 2020
base = self.env['account.move'].create([
self._invoice_vals(date=date(2020, month, 1) - timedelta(days=1))
for month in range(1, 11)
])
base.action_post()
# No issue in having an invoice missing a period, it is the vendor's responsibility
move_november = self.env['account.move'].create(self._invoice_vals(date=date(2020, 11, 30)))
self.assertFalse(move_november.abnormal_date_warning)
# The next invoice being on the last day of november is expected
move_october = self.env['account.move'].create(self._invoice_vals(date=date(2020, 10, 31)))
self.assertFalse(move_october.abnormal_date_warning)
# But any invoice before the threshold is not expected
move_october2 = self.env['account.move'].create(self._invoice_vals(date=date(2020, 10, 20)))
self.assertTrue(move_october2.abnormal_date_warning)
# If we posted the one with the abnormal date, then the other one becomes abnormal
move_october2._post(soft=False)
move_october.invalidate_recordset(['abnormal_date_warning'])
self.assertTrue(move_october.abnormal_date_warning)