823 lines
35 KiB
Python
823 lines
35 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import json
|
|
import logging
|
|
from contextlib import ExitStack, contextmanager, nullcontext
|
|
from unittest import SkipTest, mock
|
|
from unittest.mock import patch
|
|
|
|
from odoo import modules
|
|
from odoo.exceptions import UserError
|
|
from odoo.fields import Command
|
|
from odoo.tests.common import TransactionCase, freeze_time, tagged
|
|
from odoo.tools import file_open
|
|
|
|
from .mocked_invoice_response import generate_response
|
|
from .mocked_credit_note_response import generate_response as credit_note_generate_response
|
|
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
|
from odoo.addons.l10n_br_avatax.models.account_external_tax_mixin import (
|
|
AccountExternalTaxMixin,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
DUMMY_SANDBOX_ID = "DUMMY_ID"
|
|
DUMMY_SANDBOX_KEY = "DUMMY_KEY"
|
|
TEST_DATETIME = "2025-02-05T22:55:17+00:00"
|
|
|
|
|
|
class TestBRMockedRequests(TransactionCase):
|
|
@classmethod
|
|
def setUpClass(self):
|
|
super().setUpClass()
|
|
# Any additional patches that need to be applied for the tests can be added here.
|
|
self.mocked_l10n_br_iap_patches = []
|
|
|
|
@contextmanager
|
|
def _with_mocked_l10n_br_iap_request(self, expected_communications):
|
|
"""Checks that we send the right requests and returns corresponding mocked responses. Heavily inspired by
|
|
patch_session in l10n_ke_edi_oscu."""
|
|
module = self.test_module
|
|
self.maxDiff = None
|
|
test_case = self
|
|
json_module = json
|
|
expected_communications = iter(expected_communications)
|
|
|
|
def mocked_l10n_br_iap_request(self, route, company, json=None):
|
|
edi_installed = self.env['ir.module.module']._get('l10n_br_edi').state == 'installed'
|
|
|
|
def replace_ignore(dict_to_replace):
|
|
"""Replace `___ignore___` in the expected request JSONs by unittest.mock.ANY,
|
|
which is equal to everything. In addition, itemCode is always added to all tax
|
|
requests if l10n_br_edi is installed. This means that a test case could pass with
|
|
a specific file if only l10n_br_avatax is installed but fail if l10n_br_edi
|
|
is also installed (or vise versa). As such, we skip it if edi is not installed that
|
|
way we don't need to duplicate input files."""
|
|
new_dict = {}
|
|
for k, v in dict_to_replace.items():
|
|
if k == 'itemCode' and not edi_installed:
|
|
continue
|
|
if v == "___ignore___":
|
|
v = mock.ANY
|
|
new_dict[k] = v
|
|
return new_dict
|
|
|
|
expected_route, expected_request_filename, expected_response_filename = next(expected_communications)
|
|
test_case.assertEqual(route, expected_route)
|
|
with file_open(f"{module}/tests/mocked_requests/{expected_request_filename}.json", "r") as request_file:
|
|
expected_request = json_module.loads(request_file.read(), object_hook=replace_ignore)
|
|
test_case.assertEqual(
|
|
json,
|
|
expected_request,
|
|
f"Expected request did not match actual request for route {route}.",
|
|
)
|
|
|
|
with file_open(f"{module}/tests/mocked_responses/{expected_response_filename}.json", "r") as response_file:
|
|
api_response = json_module.loads(response_file.read())
|
|
|
|
if expected_route == "calculate_tax":
|
|
expected_lines = api_response["lines"]
|
|
|
|
# Generically get line information for any record type that supports the
|
|
# account.external.tax.mixin.
|
|
record_model, record_id = json['header']['documentCode'].split('_')
|
|
record = self.env[record_model].browse(int(record_id))
|
|
lines = [
|
|
line['base_line']['record']
|
|
for line in record._get_line_data_for_external_taxes()
|
|
]
|
|
|
|
test_case.assertEqual(
|
|
len(lines), len(expected_lines), f"The sent record was expected to have {len(expected_lines)} lines.",
|
|
)
|
|
|
|
# Set the line IDs in the mocked response to the line IDs of this records.
|
|
for i, line in enumerate(expected_lines):
|
|
line["lineCode"] = lines[i].id
|
|
|
|
return api_response
|
|
|
|
with ExitStack() as patch_stack:
|
|
patch_stack.enter_context(patch(
|
|
f"{AccountExternalTaxMixin.__module__}.AccountExternalTaxMixin._l10n_br_iap_request",
|
|
autospec=True,
|
|
side_effect=mocked_l10n_br_iap_request,
|
|
))
|
|
# Apply all other patches in addition to the required ones.
|
|
for other_patch in self.mocked_l10n_br_iap_patches:
|
|
patch_stack.enter_context(other_patch)
|
|
yield
|
|
|
|
if next(expected_communications, None):
|
|
self.fail("Not all expected calls were made!")
|
|
|
|
|
|
@tagged('post_install_l10n', '-at_install', 'post_install')
|
|
class TestAvalaraBrCommon(AccountTestInvoicingCommon, TestBRMockedRequests):
|
|
@classmethod
|
|
@AccountTestInvoicingCommon.setup_country('br')
|
|
def setUpClass(cls):
|
|
res = super().setUpClass()
|
|
cls._setup_credentials()
|
|
cls.foreign_currency = cls.setup_other_currency('EUR')
|
|
cls.fp_avatax = cls.env['account.fiscal.position'].create({
|
|
'name': 'Avatax Brazil',
|
|
'l10n_br_is_avatax': True,
|
|
})
|
|
|
|
cls._setup_partners()
|
|
|
|
# Ensure the IAP service exists for this company. Otherwise, iap.account's get() method will fail.
|
|
iap_service = cls.env.ref('l10n_br_avatax.iap_service_br_avatax')
|
|
cls.env['iap.account'].create(
|
|
{
|
|
'service_id': iap_service.id,
|
|
'company_ids': [(6, 0, cls.company_data['company'].ids)],
|
|
}
|
|
)
|
|
|
|
cls._setup_products()
|
|
|
|
return res
|
|
|
|
@classmethod
|
|
def _setup_credentials(cls):
|
|
# Set real credentials here to run the integration tests
|
|
cls.env.company.l10n_br_avatax_api_identifier = DUMMY_SANDBOX_ID
|
|
cls.env.company.l10n_br_avatax_api_key = DUMMY_SANDBOX_KEY
|
|
cls.env.company.l10n_br_avalara_environment = 'sandbox'
|
|
|
|
@classmethod
|
|
def _setup_partners(cls):
|
|
company = cls.company_data['company']
|
|
company.write({
|
|
'street': 'Rua Marechal Deodoro 630',
|
|
'street2': 'Edificio Centro Comercial Itália 24o Andar',
|
|
'city': 'Curitiba',
|
|
'state_id': cls.env.ref('base.state_br_pr').id,
|
|
'country_id': cls.env.ref('base.br').id,
|
|
'zip': '80010-010',
|
|
})
|
|
company.partner_id.l10n_br_tax_regime = 'individual'
|
|
|
|
cls.partner = cls.env['res.partner'].create({
|
|
'name': 'Avatax Brazil Test Partner',
|
|
'street': 'Avenida SAP, 188',
|
|
'street2': 'Cristo Rei',
|
|
'city': 'São Leopoldo',
|
|
'state_id': cls.env.ref('base.state_br_rs').id,
|
|
'country_id': cls.env.ref('base.br').id,
|
|
'zip': '93022-718',
|
|
'property_account_position_id': cls.fp_avatax.id,
|
|
'l10n_br_tax_regime': 'individual',
|
|
})
|
|
|
|
cls.partner_shipping_id = cls.env['res.partner'].create({
|
|
'type': 'delivery',
|
|
'street_name': 'Avenida Europa',
|
|
'street_number': '2048',
|
|
'street2': 'Jardim São Domingos',
|
|
'state_id': cls.env.ref('base.state_br_sp').id,
|
|
'city_id': cls.env.ref('l10n_br.city_br_124').id,
|
|
'country_id': cls.env.ref('base.br').id,
|
|
'city': 'Americana',
|
|
})
|
|
|
|
@classmethod
|
|
def _setup_products(cls):
|
|
common = {
|
|
'l10n_br_ncm_code_id': cls.env.ref('l10n_br_avatax.49011000').id,
|
|
'l10n_br_source_origin': '0',
|
|
'l10n_br_sped_type': 'FOR PRODUCT',
|
|
'l10n_br_use_type': 'use or consumption',
|
|
'supplier_taxes_id': None,
|
|
}
|
|
|
|
cls.product = cls.env['product.product'].create({
|
|
'name': 'Product',
|
|
'default_code': 'PROD1',
|
|
'barcode': '123456789',
|
|
'list_price': 15.00,
|
|
'standard_price': 15.00,
|
|
**common,
|
|
})
|
|
cls.product_user = cls.env['product.product'].create({
|
|
'name': 'Odoo User',
|
|
'list_price': 35.00,
|
|
'standard_price': 35.00,
|
|
**common,
|
|
})
|
|
cls.product_user_discount = cls.env['product.product'].create({
|
|
'name': 'Odoo User Initial Discount',
|
|
'list_price': -5.00,
|
|
'standard_price': -5.00,
|
|
**common,
|
|
})
|
|
cls.product_accounting = cls.env['product.product'].create({
|
|
'name': 'Accounting',
|
|
'list_price': 30.00,
|
|
'standard_price': 30.00,
|
|
**common,
|
|
})
|
|
cls.product_expenses = cls.env['product.product'].create({
|
|
'name': 'Expenses',
|
|
'list_price': 15.00,
|
|
'standard_price': 15.00,
|
|
**common,
|
|
})
|
|
cls.product_invoicing = cls.env['product.product'].create({
|
|
'name': 'Invoicing',
|
|
'list_price': 15.00,
|
|
'standard_price': 15.00,
|
|
**common,
|
|
})
|
|
|
|
@classmethod
|
|
@contextmanager
|
|
def _skip_no_credentials(cls):
|
|
company = cls.env.company
|
|
if company.l10n_br_avatax_api_identifier == DUMMY_SANDBOX_ID or \
|
|
company.l10n_br_avatax_api_key == DUMMY_SANDBOX_KEY or \
|
|
company.l10n_br_avalara_environment != 'sandbox':
|
|
raise SkipTest('no Avalara credentials')
|
|
yield
|
|
|
|
@classmethod
|
|
@contextmanager
|
|
def _capture_request_br(cls, return_value=None):
|
|
with patch(f'{AccountExternalTaxMixin.__module__}.AccountExternalTaxMixin._l10n_br_iap_request', return_value=return_value) as mocked:
|
|
yield mocked
|
|
|
|
@classmethod
|
|
def _create_invoice_01_and_expected_response(cls, move_type='out_invoice'):
|
|
products = (
|
|
cls.product_user,
|
|
cls.product_accounting,
|
|
cls.product_expenses,
|
|
cls.product_invoicing,
|
|
)
|
|
invoice = cls.env['account.move'].create({
|
|
'move_type': move_type,
|
|
'partner_id': cls.partner.id,
|
|
'fiscal_position_id': cls.fp_avatax.id,
|
|
'invoice_date': '2021-01-01',
|
|
'invoice_line_ids': [
|
|
(0, 0, {
|
|
'product_id': product.id,
|
|
'tax_ids': None,
|
|
'price_unit': product.list_price,
|
|
}) for product in products
|
|
],
|
|
})
|
|
invoice.invoice_line_ids[0].discount = 10
|
|
|
|
return invoice, generate_response(invoice.invoice_line_ids)
|
|
|
|
@classmethod
|
|
def _create_invoice_02(cls, operation_types=False):
|
|
products = (
|
|
cls.product_user,
|
|
cls.product_accounting,
|
|
cls.product_expenses,
|
|
cls.product_invoicing,
|
|
)
|
|
|
|
operation_types = operation_types or (
|
|
cls.env.ref('l10n_br_avatax.operation_type_1'),
|
|
cls.env.ref('l10n_br_avatax.operation_type_2'),
|
|
cls.env.ref('l10n_br_avatax.operation_type_3'),
|
|
cls.env.ref('l10n_br_avatax.operation_type_60'),
|
|
)
|
|
invoice = cls.env['account.move'].create({
|
|
'move_type': 'out_invoice',
|
|
'partner_id': cls.partner.id,
|
|
'fiscal_position_id': cls.fp_avatax.id,
|
|
'invoice_date': '2021-01-01',
|
|
'invoice_line_ids': [
|
|
Command.create({
|
|
'product_id': product.id,
|
|
'tax_ids': None,
|
|
'price_unit': product.list_price,
|
|
'l10n_br_goods_operation_type_id': operation_type and operation_type.id,
|
|
}) for product, operation_type in zip(products, operation_types)
|
|
],
|
|
})
|
|
|
|
return invoice
|
|
|
|
@classmethod
|
|
def _create_invoice_with_diff_partner_shipping(cls):
|
|
invoice = cls._create_invoice_02(operation_types=(False, ) * 4)
|
|
# Create a delivery address for partner
|
|
partner_shipping_id = cls.partner_shipping_id
|
|
invoice.partner_id.write({
|
|
'child_ids': (4, partner_shipping_id.id),
|
|
'l10n_br_tax_regime': 'realProfit',
|
|
'city_id': cls.env.ref("l10n_br.city_br_002"),
|
|
})
|
|
# Default document type is NF-e
|
|
invoice.write({
|
|
'invoice_date': TEST_DATETIME,
|
|
'l10n_latam_document_type_id': cls.env.ref('l10n_br.dt_55').id,
|
|
'l10n_br_cnae_code_id': cls.env.ref("l10n_br_avatax.cnae_6209100").id,
|
|
'partner_shipping_id': partner_shipping_id.id
|
|
})
|
|
return invoice
|
|
|
|
|
|
class TestAvalaraBrInvoiceCommon(TestAvalaraBrCommon):
|
|
def assertInvoice(self, invoice, test_exact_response):
|
|
self.assertEqual(
|
|
len(invoice.invoice_line_ids.tax_ids),
|
|
0,
|
|
'There should be no tax rate on the line.'
|
|
)
|
|
|
|
self.assertRecordValues(invoice, [{
|
|
'amount_total': 91.50,
|
|
'amount_untaxed': 91.50,
|
|
'amount_tax': 0.0,
|
|
}])
|
|
|
|
# When the external tests run this will need to do an IAP request which isn't possible in testing mode, see:
|
|
# 7416acc111793ac1f7fd0dc653bb05cf7af28ebe
|
|
with patch.object(modules.module, 'current_test', False) if 'external_l10n' in self.test_tags else nullcontext():
|
|
invoice.action_post()
|
|
|
|
if test_exact_response:
|
|
expected_amounts = {
|
|
'amount_total': 91.50,
|
|
'amount_untaxed': 91.50 - 10.98 - 5.02,
|
|
'amount_tax': 10.98 + 5.02,
|
|
}
|
|
self.assertRecordValues(invoice, [expected_amounts])
|
|
|
|
self.assertEqual(invoice.tax_totals['total_amount_currency'], expected_amounts['amount_total'])
|
|
self.assertEqual(invoice.tax_totals['base_amount_currency'], expected_amounts['amount_untaxed'])
|
|
|
|
self.assertEqual(len(invoice.tax_totals['subtotals']), 1)
|
|
self.assertEqual(invoice.tax_totals['subtotals'][0]['base_amount_currency'], expected_amounts['amount_untaxed'])
|
|
|
|
avatax_mapping = {avatax_line['lineCode']: avatax_line for avatax_line in test_exact_response['lines']}
|
|
for line in invoice.invoice_line_ids:
|
|
avatax_line = avatax_mapping[line.id]
|
|
self.assertEqual(
|
|
line.price_total,
|
|
avatax_line['lineAmount'] - avatax_line['lineTaxedDiscount'],
|
|
f"Tax-included price doesn't match tax returned by Avatax for line {line.id} (product: {line.product_id.display_name})."
|
|
)
|
|
self.assertAlmostEqual(
|
|
line.price_subtotal,
|
|
avatax_line['lineNetFigure'] - avatax_line['lineTaxedDiscount'],
|
|
msg=f'Wrong Avatax amount for {line.id} (product: {line.product_id.display_name}), there is probably a mismatch between the test SO and the mocked response.'
|
|
)
|
|
|
|
else:
|
|
for line in invoice.invoice_line_ids:
|
|
product_name = line.product_id.display_name
|
|
self.assertGreater(len(line.tax_ids), 0, 'Line with %s did not get any taxes set.' % product_name)
|
|
|
|
self.assertGreater(invoice.amount_tax, 0.0, 'Invoice has a tax_amount of 0.0.')
|
|
|
|
|
|
@tagged('post_install_l10n', '-at_install', 'post_install')
|
|
class TestAvalaraBrInvoice(TestAvalaraBrInvoiceCommon):
|
|
def test_01_invoice_br(self):
|
|
invoice, response = self._create_invoice_01_and_expected_response()
|
|
with self._capture_request_br(return_value=response):
|
|
self.assertInvoice(invoice, test_exact_response=response)
|
|
|
|
def test_02_non_brl(self):
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
invoice.currency_id = self.env.ref('base.USD')
|
|
|
|
with self.assertRaisesRegex(UserError, r'.* Brazilian Real is required to calculate taxes with Avatax.'):
|
|
self.assertInvoice(invoice, test_exact_response=None)
|
|
|
|
def test_03_transport_cost(self):
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
transport_cost_products = self.env['product.product'].create([{
|
|
'name': 'freight',
|
|
'list_price': 10.00,
|
|
'l10n_br_transport_cost_type': 'freight',
|
|
}, {
|
|
'name': 'insurance',
|
|
'list_price': 20.00,
|
|
'l10n_br_transport_cost_type': 'insurance',
|
|
}, {
|
|
'name': 'other',
|
|
'list_price': 30.00,
|
|
'l10n_br_transport_cost_type': 'other',
|
|
}])
|
|
|
|
for product in transport_cost_products:
|
|
self.env['account.move.line'].create({
|
|
'product_id': product.id,
|
|
'price_unit': product.list_price,
|
|
'move_id': invoice.id,
|
|
})
|
|
|
|
# (line amount, freight, insurance, other) per line
|
|
expecteds = [
|
|
(35.00, 3.68, 7.37, 11.05),
|
|
(30.00, 3.16, 6.32, 9.47),
|
|
(15.00, 1.58, 3.16, 4.74),
|
|
(15.00, 1.58, 3.15, 4.74), # note that the insurance amount is different from the line above to ensure the total adds up to 20
|
|
]
|
|
|
|
api_request = invoice._prepare_l10n_br_avatax_document_service_call(invoice._get_l10n_br_avatax_service_params())
|
|
actual_lines = api_request['lines']
|
|
self.assertEqual(len(expecteds), len(actual_lines), 'Different amount of expected and actual lines.')
|
|
|
|
for expected, line in zip(expecteds, actual_lines):
|
|
amount, freight, insurance, other = expected
|
|
self.assertEqual(amount, line['lineAmount'])
|
|
self.assertEqual(freight, line['freightAmount'])
|
|
self.assertEqual(insurance, line['insuranceAmount'])
|
|
self.assertEqual(other, line['otherCostAmount'])
|
|
|
|
def test_04_negative_line(self):
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
self.env['account.move.line'].create({
|
|
'product_id': self.product_user_discount.id,
|
|
'move_id': invoice.id,
|
|
'price_unit': -1_000.00,
|
|
})
|
|
|
|
with self._capture_request_br(), \
|
|
self.assertRaisesRegex(UserError, "Avatax Brazil doesn't support negative lines."):
|
|
invoice.action_post()
|
|
|
|
def test_05_credit_note(self):
|
|
"""Tax calculation without setting operation types on the lines. This should use the default
|
|
from the parent model instead. (salesReturn)
|
|
"""
|
|
invoice, response = self._create_invoice_01_and_expected_response()
|
|
with self._capture_request_br(return_value=response):
|
|
invoice.action_post()
|
|
|
|
credit_note_wizard = self.env['account.move.reversal'].with_context(active_model='account.move', active_ids=invoice.ids).create({
|
|
'journal_id': invoice.journal_id.id,
|
|
})
|
|
credit_note_wizard.reverse_moves()
|
|
|
|
credit_note = self.env['account.move'].search([('reversed_entry_id', '=', invoice.id)])
|
|
self.assertTrue(credit_note, "A credit note should have been created.")
|
|
|
|
payload = credit_note._prepare_l10n_br_avatax_document_service_call(credit_note._get_l10n_br_avatax_service_params())
|
|
self.assertTrue(all(line['operationType'] == 'salesReturn' for line in payload['lines']), 'The default operationType for credit notes should be salesReturn.')
|
|
self.assertEqual(payload['header']['invoicesRefs'][0]['documentCode'], f'account.move_{invoice.id}', 'The credit note should reference the original invoice.')
|
|
|
|
def test_06_unique_operation_types(self):
|
|
"""Tax calculation with unique operation types on each line."""
|
|
invoice = self._create_invoice_02()
|
|
self.assertRecordValues(invoice, [{
|
|
'amount_total': 95.0,
|
|
'amount_untaxed': 95.0,
|
|
'amount_tax': 0.0,
|
|
}])
|
|
|
|
payload = invoice._prepare_l10n_br_avatax_document_service_call(invoice._get_l10n_br_avatax_service_params())
|
|
operation_types = [line['operationType'] for line in payload['lines']]
|
|
expected_operation_types = ['standardSales', 'complementary', 'amountComplementary', 'salesReturn']
|
|
self.assertEqual(operation_types, expected_operation_types, 'The expected operation types are not properly set. It should be unique per line.')
|
|
|
|
def test_07_override_operation_type(self):
|
|
"""Tax calculation with operation types set only on a single line. The rest should default to standardSales."""
|
|
operation_types = (
|
|
False,
|
|
self.env.ref('l10n_br_avatax.operation_type_2'),
|
|
False,
|
|
False,
|
|
)
|
|
|
|
invoice = self._create_invoice_02(operation_types=operation_types)
|
|
self.assertRecordValues(invoice, [{
|
|
'amount_total': 95.0,
|
|
'amount_untaxed': 95.0,
|
|
'amount_tax': 0.0,
|
|
}])
|
|
|
|
payload = invoice._prepare_l10n_br_avatax_document_service_call(invoice._get_l10n_br_avatax_service_params())
|
|
operation_types = [line['operationType'] for line in payload['lines']]
|
|
expected_operation_types = ['standardSales', 'complementary', 'standardSales', 'standardSales']
|
|
self.assertEqual(operation_types, expected_operation_types, 'The expected operation types are not properly set.')
|
|
|
|
def test_08_vendor_bill(self):
|
|
""" Verify the differences between sending an invoice and a bill. """
|
|
bill, response = self._create_invoice_01_and_expected_response(move_type='in_invoice')
|
|
bill.l10n_latam_document_number = '1'
|
|
self.assertEqual(
|
|
bill.l10n_br_goods_operation_type_id,
|
|
self.env.ref('l10n_br_avatax.operation_type_59'),
|
|
"Default operation type for bills should be standardPurchase."
|
|
)
|
|
|
|
with self._capture_request_br(return_value=response) as patched:
|
|
bill.action_post()
|
|
|
|
payload = patched.call_args.args[2]
|
|
self.assertEqual(
|
|
payload['header']['operationType'],
|
|
'standardPurchase',
|
|
'The operationType for vendor bills should be standardPurchase.'
|
|
)
|
|
|
|
def test_09_ex_citation_in_payload(self):
|
|
""" Ensure that the 'ex' citation from the NCM code is included in the Avatax API payload. """
|
|
self.env.ref('l10n_br_avatax.49011000').write({'ex': '001'})
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
payload = invoice._prepare_l10n_br_avatax_document_service_call(invoice._get_l10n_br_avatax_service_params())
|
|
line = payload['lines'][0]
|
|
self.assertEqual(line['itemDescriptor']['ex'], '001', "EX field should match the value set in NCM code")
|
|
|
|
def test_10_tax_calculation_api_error(self):
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
response = {
|
|
"error": {
|
|
"code": "TC000",
|
|
"message": "Errors: ",
|
|
"innerError": [
|
|
{
|
|
"code": "TC001",
|
|
"message": "Cannot find TaxCitation based on NCM for PIS",
|
|
"lineCode": invoice.invoice_line_ids.ids[0],
|
|
"where": {
|
|
"type": "PIS",
|
|
"hsCodes.codeType": "NCM",
|
|
"hsCodes.code": "49011000",
|
|
"date": "2025-07-18T00:00:00.000Z",
|
|
},
|
|
"lineIndex": 0,
|
|
"itemCode": "false",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
with self._capture_request_br(return_value=response), \
|
|
self.assertRaisesRegex(UserError, "Cannot find TaxCitation based on NCM for PIS"):
|
|
invoice.button_external_tax_calculation()
|
|
|
|
@freeze_time(TEST_DATETIME)
|
|
def test_11_service_invoice(self):
|
|
""" Make sure that service invoices are handled correctly and can have CNAE overriden. """
|
|
rio_city = self.env.ref("l10n_br.city_br_002")
|
|
invoice = self._create_invoice_02(operation_types=(False, ) * 4)
|
|
invoice.invoice_line_ids.mapped('product_id').write(
|
|
{
|
|
"type": "service",
|
|
"l10n_br_property_service_code_origin_id": self.env["l10n_br.service.code"].create(
|
|
{"code": "12345", "city_id": rio_city.id},
|
|
),
|
|
},
|
|
)
|
|
|
|
invoice.write({
|
|
'invoice_date': TEST_DATETIME,
|
|
'l10n_latam_document_type_id': self.env.ref('l10n_br.dt_SE').id,
|
|
'l10n_br_cnae_code_id': self.env.ref("l10n_br_avatax.cnae_6209100").id,
|
|
})
|
|
invoice.partner_id.city_id = rio_city
|
|
|
|
ncm_code_id = self.env.ref('l10n_br_avatax.49021000')
|
|
ncm_code_id.l10n_br_cnae_code_id = self.env.ref('l10n_br_avatax.cnae_6204000')
|
|
invoice.invoice_line_ids[-1].product_id.l10n_br_ncm_code_id = ncm_code_id
|
|
|
|
with self._with_mocked_l10n_br_iap_request([
|
|
("calculate_tax", "anonymous_tax_request", "anonymous_tax_response"),
|
|
]):
|
|
invoice.action_post()
|
|
|
|
self.assertRecordValues(invoice, [{
|
|
'amount_total': 95.0,
|
|
'amount_untaxed': 95.0,
|
|
'amount_tax': 0.0,
|
|
}])
|
|
|
|
def test_12_service_invoice_with_installments(self):
|
|
"""Test that service invoices with installments clear tax info when using Avalara. It's necessary because Avalara
|
|
expects installments to be sent without taxes for service invoices."""
|
|
invoice, response = self._create_invoice_01_and_expected_response()
|
|
rio_city = self.env.ref("l10n_br.city_br_002")
|
|
|
|
invoice.invoice_payment_term_id = self.pay_terms_b.id
|
|
invoice.l10n_latam_document_type_id = self.env.ref("l10n_br.dt_SE").id
|
|
invoice.partner_id.city_id = rio_city
|
|
|
|
# Mark all products as services and assign service code
|
|
for line in invoice.invoice_line_ids:
|
|
line.tax_ids = self.tax_sale_a
|
|
line.product_id.write({
|
|
'type': 'service',
|
|
'l10n_br_property_service_code_origin_id': self.env['l10n_br.service.code'].create({
|
|
'code': '12345',
|
|
'city_id': rio_city.id,
|
|
}),
|
|
})
|
|
|
|
# Ensure there's a tax amount
|
|
self.assertGreater(invoice.amount_tax, 0, "There should be a tax amount on this invoice.")
|
|
|
|
with self._capture_request_br(return_value=response) as captured:
|
|
invoice.button_external_tax_calculation()
|
|
|
|
payload = captured.call_args.args[2]
|
|
|
|
with self._capture_request_br(return_value=response) as captured:
|
|
invoice.button_external_tax_calculation()
|
|
|
|
self.assertEqual(payload, captured.call_args.args[2], "The payload of the second call should be the same")
|
|
|
|
def test_11_service_invoice_with_discount(self):
|
|
invoice, response = self._create_invoice_01_and_expected_response()
|
|
invoice.invoice_line_ids.product_id.type = 'service'
|
|
invoice.l10n_latam_document_type_id = self.env.ref('l10n_br.dt_SE')
|
|
invoice.partner_id.city_id = self.env.ref('l10n_br.city_br_001')
|
|
|
|
with self._capture_request_br(return_value=response):
|
|
invoice.action_post()
|
|
|
|
self.assertEqual(
|
|
invoice.invoice_line_ids[0].price_total,
|
|
35.0,
|
|
"The discount shouldn't have been subtracted, it's already accounted for in lineNetFigure."
|
|
)
|
|
|
|
def test_13_service_invoice_with_rendered_address(self):
|
|
rio_city = self.env.ref("l10n_br.city_br_002")
|
|
ncm_code_id = self.env.ref('l10n_br_avatax.service_1_07')
|
|
# Make a service invoice
|
|
invoice = self._create_invoice_with_diff_partner_shipping()
|
|
invoice.l10n_latam_document_type_id = self.env.ref('l10n_br.dt_SE').id
|
|
# Configure the product to be a service type for rendered address
|
|
invoice.invoice_line_ids.mapped('product_id').write(
|
|
{
|
|
"type": "service",
|
|
"l10n_br_property_service_code_origin_id": self.env["l10n_br.service.code"].create(
|
|
{"code": "1.07", "city_id": rio_city.id},
|
|
),
|
|
"l10n_br_ncm_code_id": ncm_code_id,
|
|
},
|
|
)
|
|
|
|
with self._with_mocked_l10n_br_iap_request([
|
|
("calculate_tax", "nfse_rendered_address_request", "nfse_rendered_address_response"),
|
|
]):
|
|
invoice.action_post()
|
|
|
|
def test_14_goods_invoice_with_delivery_address(self):
|
|
invoice = self._create_invoice_with_diff_partner_shipping()
|
|
payload = invoice._prepare_l10n_br_avatax_document_service_call(invoice._get_l10n_br_avatax_service_params())
|
|
delivery = payload['header']['locations'].get('delivery')
|
|
self.assertTrue(delivery, "Delivery address should be sent in request when partner_shipping_id is not the same as partner_id")
|
|
|
|
def test_15_credit_note_with_included_tax(self):
|
|
product = self.env['product.product'].create({
|
|
'name': 'Test Product',
|
|
'default_code': 'PROD2',
|
|
'list_price': 800.00,
|
|
'standard_price': 800.00,
|
|
'l10n_br_ncm_code_id': self.env.ref('l10n_br_avatax.02062990').id,
|
|
'l10n_br_source_origin': '0',
|
|
'l10n_br_sped_type': 'FOR PRODUCT',
|
|
'l10n_br_use_type': 'production',
|
|
'supplier_taxes_id': None,
|
|
})
|
|
|
|
credit_note = self.env['account.move'].create({
|
|
'move_type': 'out_refund',
|
|
'partner_id': self.partner.id,
|
|
'fiscal_position_id': self.fp_avatax.id,
|
|
'invoice_date': '2021-01-01',
|
|
'invoice_line_ids': [
|
|
Command.create({
|
|
'product_id': product.id,
|
|
'tax_ids': None,
|
|
'price_unit': product.list_price,
|
|
}),
|
|
],
|
|
})
|
|
|
|
response = credit_note_generate_response(credit_note.invoice_line_ids)
|
|
with self._capture_request_br(return_value=response):
|
|
credit_note.action_post()
|
|
|
|
expected_amounts = {
|
|
'amount_total': 800.0,
|
|
'amount_untaxed': 704.0,
|
|
'amount_tax': 96.0,
|
|
}
|
|
self.assertRecordValues(credit_note, [expected_amounts])
|
|
self.assertEqual(credit_note.tax_totals['total_amount_currency'], expected_amounts['amount_total'])
|
|
self.assertEqual(credit_note.tax_totals['base_amount_currency'], expected_amounts['amount_untaxed'])
|
|
|
|
def test_13_stale_manual_total_excluded_currency(self):
|
|
"""Test that manual_total_excluded_currency is updated on recomputation even if it already has a value."""
|
|
invoice, response = self._create_invoice_01_and_expected_response()
|
|
|
|
# First call to populate extra_tax_data with correct values.
|
|
with self._capture_request_br(return_value=response):
|
|
invoice.button_external_tax_calculation()
|
|
|
|
# Corrupt the manual_total_excluded_currency in extra_tax_data.
|
|
for line in invoice.invoice_line_ids:
|
|
extra_tax_data = line.extra_tax_data
|
|
extra_tax_data['manual_total_excluded_currency'] = 123
|
|
line.write({'extra_tax_data': extra_tax_data})
|
|
|
|
# Second call, should correct the stale manual_total_excluded_currency.
|
|
with self._capture_request_br(return_value=response):
|
|
invoice.button_external_tax_calculation()
|
|
|
|
# Verify that manual_total_excluded_currency was updated from the fresh Avatax response.
|
|
pre_tax_base = invoice.invoice_line_ids[0].extra_tax_data.get('manual_total_excluded_currency')
|
|
self.assertNotEqual(pre_tax_base, 123)
|
|
|
|
|
|
@tagged('post_install_l10n', '-at_install', 'post_install')
|
|
class TestAvalaraBrSettings(TestAvalaraBrInvoiceCommon):
|
|
@classmethod
|
|
@AccountTestInvoicingCommon.setup_country('br')
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.settings = cls.env['res.config.settings'].create({})
|
|
cls.settings.l10n_br_avatax_portal_email = "test@example.com"
|
|
cls.settings.company_id.vat = "00.623.904/0001-73"
|
|
|
|
def test_01_create_account_success(self):
|
|
return_value = {
|
|
'avalara_api_id': 'API_ID',
|
|
'avalara_api_key': 'API_KEY',
|
|
}
|
|
with self._capture_request_br(return_value=return_value):
|
|
self.settings.create_account()
|
|
|
|
self.assertRecordValues(self.env.company, [{
|
|
'l10n_br_avatax_api_identifier': 'API_ID',
|
|
'l10n_br_avatax_api_key': 'API_KEY',
|
|
}])
|
|
|
|
def test_02_create_account_error_type_1(self):
|
|
return_value = {
|
|
'message': 'One or more errors occurred. (CEP \'32516-076\' not found)',
|
|
'isError': True,
|
|
}
|
|
with self._capture_request_br(return_value=return_value), \
|
|
self.assertRaisesRegex(UserError, r'One or more errors occurred. \(CEP \'32516-076\' not found\)'):
|
|
self.settings.create_account()
|
|
|
|
return_value = {
|
|
'message': 'An unhandled error occurred. Trace ID: xxx',
|
|
'isError': True
|
|
}
|
|
with self._capture_request_br(return_value=return_value), \
|
|
self.assertRaisesRegex(UserError, 'Please ensure the address on your company is correct'):
|
|
self.settings.create_account()
|
|
|
|
def test_03_create_account_error_type_2(self):
|
|
return_value = {
|
|
'message': '{"errors":{"Login do usuário master":["Login já utlizado"]},"title":"One or more validation errors occurred.","status":400,"traceId":"0HMPVCEB27KLU:000000E5"}',
|
|
'isError': True,
|
|
}
|
|
|
|
with self._capture_request_br(return_value=return_value), \
|
|
self.assertRaisesRegex(UserError, 'Login já utlizado'):
|
|
self.settings.create_account()
|
|
|
|
def test_04_no_false(self):
|
|
""" Do not send "false" to the API for empty fields. It will populate "false" in some of the fields on Avatax's side
|
|
and cause issues during EDI. """
|
|
with self._capture_request_br(return_value={}) as mocked_request:
|
|
self.settings.create_account()
|
|
|
|
for k, v in mocked_request.call_args[0][2].items():
|
|
self.assertNotEqual(v, False, f"{k} was False instead of empty string")
|
|
|
|
def test_05_formatted_vat(self):
|
|
""" Properly format the VAT numbers to CNPJ even in compact form."""
|
|
with self._capture_request_br(return_value={}) as mocked_request:
|
|
self.settings.create_account()
|
|
|
|
arguments = mocked_request.call_args[0][2]
|
|
self.assertEqual(self.settings.company_id.vat, '00623904000173', 'CNPJ should be compacted in internal storage')
|
|
self.assertEqual(arguments['cnpj'], '00.623.904/0001-73', 'CNPJ must be formatted for account creation')
|
|
|
|
def test_06_extract_tax_values_multiple_moves(self):
|
|
"""Ensure that the tax values are extracted correctly when multiple moves are passed to the function.
|
|
"""
|
|
invoice_1, response = self._create_invoice_01_and_expected_response()
|
|
invoice_2, _dummy = self._create_invoice_01_and_expected_response()
|
|
invoices = invoice_1 | invoice_2
|
|
|
|
with self._capture_request_br(return_value=response):
|
|
invoices.action_post()
|
|
self.assertTrue(all(invoice.state == 'posted' for invoice in invoices))
|
|
|
|
|
|
@tagged('external_l10n', 'external', '-at_install', 'post_install', '-standard')
|
|
class TestAvalaraBrInvoiceIntegration(TestAvalaraBrInvoiceCommon):
|
|
def test_01_invoice_integration_br(self):
|
|
with self._skip_no_credentials():
|
|
invoice, _ = self._create_invoice_01_and_expected_response()
|
|
self.assertInvoice(invoice, test_exact_response=False)
|