412 lines
20 KiB
Python
412 lines
20 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import re
|
|
from contextlib import contextmanager
|
|
from copy import deepcopy
|
|
from unittest.mock import patch, DEFAULT
|
|
|
|
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
|
|
from odoo.addons.account.tests.test_account_move_send import TestAccountMoveSendCommon
|
|
from odoo.addons.iap import InsufficientCreditError
|
|
from odoo.addons.l10n_br_edi.tests.data_invoice_1 import (
|
|
invoice_1_request,
|
|
invoice_1_submit_success_response,
|
|
invoice_1_submit_fail_response,
|
|
invoice_1_cancel_success_response,
|
|
invoice_1_correct_success_response,
|
|
invoice_1_correct_fail_response, invoice_1_cancel_fail_response,
|
|
)
|
|
from .mocked_successful_status_response import RESPONSE as SUCCESSFUL_STATUS_RESPONSE
|
|
from odoo.exceptions import UserError, ValidationError, AccessError
|
|
from odoo.tests import tagged
|
|
|
|
|
|
@tagged("post_install_l10n", "post_install", "-at_install")
|
|
class TestL10nBREDICommon(TestAccountMoveSendCommon):
|
|
@classmethod
|
|
@AccountTestInvoicingCommon.setup_country('br')
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
company = cls.company_data["company"]
|
|
company.write({
|
|
"l10n_br_avatax_api_identifier": "DUMMY",
|
|
"l10n_br_avatax_api_key": "DUMMY",
|
|
})
|
|
company.partner_id.write(
|
|
{
|
|
"street": "Rua Marechal Deodoro",
|
|
"street2": "Centro",
|
|
"city": "Curitaba",
|
|
"state_id": cls.env.ref("base.state_br_pr").id,
|
|
"zip": "80010-010",
|
|
"country_id": cls.env.ref("base.br").id,
|
|
"vat": "49233848000150",
|
|
"l10n_br_ie_code": "9102799558",
|
|
}
|
|
)
|
|
cls.partner_customer = cls.env["res.partner"].create(
|
|
{
|
|
"name": "BR Company Customer",
|
|
"street": "Av. Presidente Vargas 592",
|
|
"street2": "Centros",
|
|
"city": "Rio de Janeiro",
|
|
"state_id": cls.env.ref("base.state_br_rj").id,
|
|
"zip": "20071-001",
|
|
"vat": "51494569013170",
|
|
}
|
|
)
|
|
|
|
cls.product_screens = cls.env["product.template"].create(
|
|
{
|
|
"name": "Acoustic Bloc Screens",
|
|
"list_price": 295.00,
|
|
"default_code": "FURN_6666",
|
|
"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",
|
|
}
|
|
)
|
|
cls.product_cabinet = cls.env["product.template"].create(
|
|
{
|
|
"name": "Cabinet with Doors",
|
|
"list_price": 140.00,
|
|
"default_code": "E-COM11",
|
|
"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",
|
|
}
|
|
)
|
|
|
|
cls.avatax_fp = cls.env["account.fiscal.position"].create({"name": "Avatax Brazil", "l10n_br_is_avatax": True})
|
|
|
|
def create_move(extra_vals):
|
|
move = cls.env["account.move"].create({
|
|
"partner_id": cls.partner_customer.id,
|
|
"invoice_date": "2023-10-05",
|
|
"currency_id": cls.env.ref("base.BRL").id,
|
|
"fiscal_position_id": cls.avatax_fp.id,
|
|
"invoice_line_ids": [
|
|
(
|
|
0,
|
|
0,
|
|
{"product_id": cls.product_screens.product_variant_id.id},
|
|
),
|
|
(
|
|
0,
|
|
0,
|
|
{"product_id": cls.product_cabinet.product_variant_id.id},
|
|
),
|
|
],
|
|
**extra_vals,
|
|
})
|
|
cls._post_move_with_patched_response(move)
|
|
return move
|
|
|
|
cls.invoice = create_move({"move_type": "out_invoice"})
|
|
cls.bill = create_move({"move_type": "in_invoice", "l10n_latam_document_number": "1"})
|
|
|
|
@classmethod
|
|
def _post_move_with_patched_response(cls, move):
|
|
request = deepcopy(invoice_1_request)
|
|
# Set the right lineCode on the mocked response for the tests that need it.
|
|
for line, aml in zip(request["lines"], move.invoice_line_ids):
|
|
line["lineCode"] = aml.id
|
|
|
|
with patch(
|
|
"odoo.addons.l10n_br_avatax.models.account_external_tax_mixin.AccountExternalTaxMixin._l10n_br_iap_request",
|
|
new=lambda *args, **kwargs: request,
|
|
):
|
|
move.action_post()
|
|
|
|
@contextmanager
|
|
def with_patched_account_move(self, method_name, mocked_response=None):
|
|
with patch.object(
|
|
type(self.env["account.move"]),
|
|
method_name,
|
|
new=(lambda *args, **kwargs: mocked_response) if mocked_response is not None else DEFAULT,
|
|
autospec=not mocked_response,
|
|
) as patched_fn:
|
|
yield patched_fn
|
|
|
|
|
|
@tagged("post_install_l10n", "post_install", "-at_install")
|
|
class TestL10nBREDI(TestL10nBREDICommon):
|
|
|
|
def test_l10n_br_edi_generate_and_send_single_wizard(self):
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id})
|
|
self.assertEqual(single_wizard.sending_methods, ['email'])
|
|
self.assertEqual(single_wizard.invoice_edi_format, False)
|
|
self.assertTrue('br_edi' in single_wizard.extra_edis)
|
|
with self.with_patched_account_move("_l10n_br_iap_request", invoice_1_submit_success_response):
|
|
single_wizard.action_send_and_print()
|
|
|
|
def test_l10n_br_edi_generate_and_send_batch_wizard(self):
|
|
self.partner_customer.invoice_sending_method = 'manual'
|
|
batch_wizard = self.env['account.move.send.batch.wizard'].create({'move_ids': self.invoice.ids})
|
|
self.assertTrue(batch_wizard.summary_data)
|
|
with self.with_patched_account_move("_l10n_br_iap_request", invoice_1_submit_success_response):
|
|
batch_wizard.action_send_and_print()
|
|
|
|
def test_l10n_br_edi_is_disabled(self):
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id, 'extra_edis': []})
|
|
self.assertFalse(single_wizard.extra_edis)
|
|
|
|
with self.with_patched_account_move("_l10n_br_iap_request") as patched_submit:
|
|
single_wizard.action_send_and_print()
|
|
|
|
self.assertFalse(patched_submit.called, "EDI should not have been called when the checkbox isn't checked.")
|
|
|
|
def test_invoice_1_success(self):
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id})
|
|
with self.with_patched_account_move("_l10n_br_iap_request", invoice_1_submit_success_response):
|
|
single_wizard.action_send_and_print()
|
|
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "accepted", "The EDI document should be accepted.")
|
|
self.assertEqual(
|
|
self.invoice.invoice_pdf_report_file.decode(),
|
|
invoice_1_submit_success_response["pdf"]["base64"],
|
|
"PDF data should have been saved on the EDI document.",
|
|
)
|
|
self.assertEqual(
|
|
self.invoice.l10n_br_edi_xml_attachment_id.datas.decode(),
|
|
invoice_1_submit_success_response["xml"]["base64"],
|
|
"XML data should have been saved on the EDI document.",
|
|
)
|
|
self.assertEqual(
|
|
self.invoice.l10n_br_access_key,
|
|
invoice_1_submit_success_response["key"],
|
|
"The access key should have been saved.",
|
|
)
|
|
self.assertFalse(self.invoice.l10n_br_edi_avatax_data, "Saved Avatax tax data should have been removed.")
|
|
|
|
def test_invoice_1_fail(self):
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id})
|
|
with self.with_patched_account_move(
|
|
"_l10n_br_iap_request", invoice_1_submit_fail_response
|
|
), self.assertRaisesRegex(UserError, re.escape(invoice_1_submit_fail_response["error"]["message"])):
|
|
single_wizard.action_send_and_print()
|
|
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "error", "The EDI document should be in error.")
|
|
self.assertFalse(
|
|
self.invoice.invoice_pdf_report_file,
|
|
"There should be no PDF data on the EDI document.",
|
|
)
|
|
self.assertFalse(
|
|
self.invoice.l10n_br_edi_xml_attachment_id,
|
|
"There should be no XML data on the EDI document.",
|
|
)
|
|
self.assertTrue(self.invoice.l10n_br_edi_avatax_data, "Saved Avatax tax data should have been kept.")
|
|
|
|
def test_invoice_1_iap_errors(self):
|
|
"""Test that we properly catch errors from IAP. We should never raise, because it would break the asynchronous
|
|
invoice_multi mode. We can't test the asynchronous mode easily, but we can test if we catch IAP errors by looking
|
|
at their format."""
|
|
@contextmanager
|
|
def wrap_iap(exception):
|
|
# If the error is prefixed with "Errors when..." we know we caught and handled it.
|
|
with patch(
|
|
"odoo.addons.l10n_br_avatax.models.account_external_tax_mixin.iap_jsonrpc", side_effect=exception
|
|
), self.assertRaisesRegex(UserError, "Errors when submitting the e-invoice:"):
|
|
yield
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id})
|
|
for Exc in (UserError, AccessError, InsufficientCreditError):
|
|
with wrap_iap(Exc("test")):
|
|
try:
|
|
single_wizard.action_send_and_print()
|
|
finally:
|
|
self.invoice.l10n_br_last_edi_status = False
|
|
|
|
def test_bill_1_success(self):
|
|
self.bill.journal_id.l10n_br_invoice_serial = '1'
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.bill.id})
|
|
with self.with_patched_account_move("_l10n_br_iap_request", invoice_1_submit_success_response):
|
|
single_wizard.action_send_and_print()
|
|
|
|
self.assertEqual(self.bill.l10n_br_last_edi_status, "accepted", "The EDI document should be accepted.")
|
|
|
|
def test_bill_2_without_series_number(self):
|
|
""" If no series number is configured, we consider it to be a non-electronic journal. """
|
|
self.bill.journal_id.l10n_br_invoice_serial = False
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.bill.id})
|
|
self.assertNotIn('br_edi', single_wizard.extra_edis or [])
|
|
|
|
def test_prepare_tax_data(self):
|
|
to_include, header = self.invoice._l10n_br_edi_get_tax_data()
|
|
|
|
for line in to_include["lines"]:
|
|
for detail in line["taxDetails"]:
|
|
self.assertFalse("ruleId" in detail and detail["ruleId"] is None, "ruleId shouldn't be sent when null.")
|
|
|
|
self.assertEqual(header["goods"]["class"], "TEST CLASS VALUE", "Test class value should be included in header.")
|
|
|
|
def test_update_cancel(self):
|
|
self.invoice.button_draft() # FIXME this test only works with a draft invoice, is it intended ?
|
|
wizard = self.env["l10n_br_edi.invoice.update"].create(
|
|
{"move_id": self.invoice.id, "mode": "cancel", "reason": "test reason with at least 15 characters"}
|
|
)
|
|
|
|
with self.with_patched_account_move("_l10n_br_iap_cancel_invoice_goods", invoice_1_cancel_success_response):
|
|
wizard.action_submit()
|
|
|
|
self.assertEqual(self.invoice.state, "cancel", "Invoice should be cancelled.")
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "cancelled", "Invoice should be EDI cancelled.")
|
|
|
|
def test_update_cancel_error(self):
|
|
wizard = self.env["l10n_br_edi.invoice.update"].create(
|
|
{"move_id": self.invoice.id, "mode": "cancel", "reason": "test reason with at least 15 characters"}
|
|
)
|
|
|
|
with self.with_patched_account_move("_l10n_br_iap_cancel_invoice_goods", invoice_1_cancel_fail_response), \
|
|
self.assertRaisesRegex(UserError, "Rejei\u00e7\u00e3o: Evento n\u00e3o atende o Schema XML espec\u00edfico"):
|
|
wizard.action_submit()
|
|
|
|
def test_update_correction(self):
|
|
wizard = self.env["l10n_br_edi.invoice.update"].create(
|
|
{"move_id": self.invoice.id, "mode": "correct", "reason": "Reason to make this correction."}
|
|
)
|
|
|
|
self.assertFalse(
|
|
self.invoice.l10n_br_edi_last_correction_number, "Invoice shouldn't have a correction number yet (is 0)."
|
|
)
|
|
original_state = self.invoice.state
|
|
original_edi_state = self.invoice.l10n_br_last_edi_status
|
|
with self.with_patched_account_move("_l10n_br_iap_correct_invoice_goods", invoice_1_correct_success_response):
|
|
wizard.action_submit()
|
|
|
|
self.assertEqual(self.invoice.state, original_state, "Invoice state shouldn't have changed.")
|
|
self.assertEqual(
|
|
self.invoice.l10n_br_last_edi_status, original_edi_state, "Invoice EDI state shouldn't have changed."
|
|
)
|
|
self.assertEqual(self.invoice.l10n_br_edi_last_correction_number, 1, "Latest correction number should be 1")
|
|
|
|
def test_update_correction_error(self):
|
|
# This will fail because the reason must be >=15 characters according to Avalara.
|
|
wizard = self.env["l10n_br_edi.invoice.update"].create(
|
|
{"move_id": self.invoice.id, "mode": "correct", "reason": "Too short."}
|
|
)
|
|
|
|
with self.with_patched_account_move(
|
|
"_l10n_br_iap_correct_invoice_goods", invoice_1_correct_fail_response
|
|
), self.assertRaisesRegex(ValidationError, re.escape(invoice_1_correct_fail_response["status"]["desc"])):
|
|
wizard.action_submit()
|
|
|
|
def test_new_invoice_attachments(self):
|
|
"""Test that newly set invoice PDFs or XMLs are reflected in the fields."""
|
|
def set_new_attachments(invoice, response):
|
|
new_attachments = invoice._l10n_br_edi_attachments_from_response(response)
|
|
new_pdf_attachment = new_attachments.filtered(lambda attachment: ".pdf" in attachment.name)
|
|
new_xml_attachment = new_attachments.filtered(lambda attachment: ".xml" in attachment.name)
|
|
|
|
self.assertEqual(invoice.invoice_pdf_report_id, new_pdf_attachment)
|
|
self.assertEqual(invoice.l10n_br_edi_xml_attachment_id, new_xml_attachment)
|
|
self.assertEqual(invoice.message_main_attachment_id, new_pdf_attachment)
|
|
|
|
set_new_attachments(self.invoice, invoice_1_submit_success_response)
|
|
set_new_attachments(self.invoice, invoice_1_submit_success_response)
|
|
|
|
def test_finnfe_goal_mapping(self):
|
|
self.invoice.l10n_br_goods_operation_type_id = self.env.ref("l10n_br_avatax.operation_type_71") # shippingLendingReturnIn
|
|
self.assertEqual(
|
|
self.invoice._l10n_br_prepare_invoice_payload()["header"]["goods"]["goal"],
|
|
"TransferBack",
|
|
"Goal should be overridden in this case."
|
|
)
|
|
|
|
self.invoice.l10n_br_goods_operation_type_id = self.env.ref("l10n_br_avatax.operation_type_51") # itemsForRepairShippingOutbound
|
|
self.assertEqual(
|
|
self.invoice._l10n_br_prepare_invoice_payload()["header"]["goods"]["goal"],
|
|
"Normal",
|
|
"Goal should fall back on normal."
|
|
)
|
|
|
|
def test_cfop_override(self):
|
|
""" Verify that CFOP is properly overridden when set in operation type. """
|
|
operation_type_6107 = self.env['l10n_br.operation.type'].create({
|
|
'name': 'Custom Operation 6107',
|
|
'technical_name': 'cfop_6107',
|
|
'l10n_br_cfop_code': '6107',
|
|
})
|
|
operation_type_6108 = self.env['l10n_br.operation.type'].create({
|
|
'name': 'Custom Operation 6108',
|
|
'technical_name': 'cfop_6108',
|
|
'l10n_br_cfop_code': '6108',
|
|
})
|
|
|
|
self.invoice.l10n_br_goods_operation_type_id = operation_type_6107
|
|
self.invoice.invoice_line_ids[1].l10n_br_goods_operation_type_id = operation_type_6108
|
|
|
|
# Repost the invoice to recalculate taxes and set the CFOPs.
|
|
self.invoice.button_draft()
|
|
self._post_move_with_patched_response(self.invoice)
|
|
|
|
payload = self.invoice._l10n_br_prepare_invoice_payload()
|
|
self.assertEqual(payload["lines"][0]["cfop"], 6107, "Should be the standard CFOP set on the invoice.")
|
|
self.assertEqual(payload["lines"][1]["cfop"], 6108, "Should be the overridden CFOP set on the operation type on the line.")
|
|
|
|
|
|
@tagged("post_install_l10n", "post_install", "-at_install")
|
|
class TestL10nBREDIServices(TestL10nBREDICommon):
|
|
|
|
def test_l10n_br_edi_informative_taxes(self):
|
|
rio_city = self.env.ref("l10n_br.city_br_002")
|
|
self.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}
|
|
),
|
|
}
|
|
)
|
|
self.invoice.partner_id.city_id = rio_city
|
|
self.invoice.l10n_latam_document_type_id = self.env.ref("l10n_br.dt_SE")
|
|
|
|
# Make sure we don't submit informative taxes
|
|
payload = self.invoice._l10n_br_prepare_invoice_payload()
|
|
self.assertTrue("informative" not in str(payload).lower())
|
|
|
|
single_wizard = self.env['account.move.send.wizard'].create({'move_id': self.invoice.id})
|
|
with self.with_patched_account_move("_l10n_br_iap_request", invoice_1_submit_success_response):
|
|
single_wizard.action_send_and_print()
|
|
|
|
informative_taxes = """Informative taxes:.*\
|
|
subtotalTaxable: 435, tax: 0, taxType: cofins.*\
|
|
subtotalTaxable: 435, tax: 0, taxType: pis.*\
|
|
subtotalTaxable: 435, tax: 58.51, taxType: aproxtribFed.*\
|
|
subtotalTaxable: 435, tax: 82.65, taxType: aproxtribState.*\
|
|
subtotalTaxable: 435, tax: 0, taxType: ipi"""
|
|
self.assertRegex(self.invoice.message_ids[-2].body, informative_taxes)
|
|
|
|
def test_l10n_br_edi_service_status_successful(self):
|
|
self.assertFalse(self.invoice.invoice_pdf_report_id, "PDF should not yet be generated.")
|
|
self.invoice.l10n_br_last_edi_status = "pending"
|
|
with self.with_patched_account_move("_l10n_br_iap_request", SUCCESSFUL_STATUS_RESPONSE):
|
|
self.invoice.button_l10n_br_edi_get_service_invoice()
|
|
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "accepted", "Invoice should be accepted.")
|
|
self.assertTrue(self.invoice.invoice_pdf_report_id, "PDF should have been saved.")
|
|
self.assertTrue(self.invoice.l10n_br_edi_xml_attachment_id, "XML should have been saved.")
|
|
|
|
def test_update_cancel_accepted_invoice(self):
|
|
"""
|
|
Test that cancelling an accepted posted invoice correctly sets EDI status to 'cancelled'.
|
|
"""
|
|
# Simulate the real-world flow: invoice is posted and accepted
|
|
self.invoice.l10n_br_last_edi_status = "accepted"
|
|
self.invoice.l10n_br_access_key = "12345678901234567890123456789012345678901234"
|
|
self.assertEqual(self.invoice.state, "posted", "Invoice should be posted.")
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "accepted", "Invoice should be accepted.")
|
|
|
|
# Create cancellation wizard
|
|
wizard = self.env["l10n_br_edi.invoice.update"].create(
|
|
{"move_id": self.invoice.id, "mode": "cancel", "reason": "test reason with at least 15 characters"}
|
|
)
|
|
|
|
# Submit cancellation
|
|
with self.with_patched_account_move("_l10n_br_iap_cancel_invoice_goods", invoice_1_cancel_success_response):
|
|
wizard.action_submit()
|
|
|
|
# Verify the invoice is cancelled and EDI status is correctly set
|
|
self.assertEqual(self.invoice.state, "cancel", "Invoice should be cancelled.")
|
|
self.assertEqual(self.invoice.l10n_br_last_edi_status, "cancelled", "Invoice EDI status should be 'cancelled'")
|