import logging import random from base64 import b64encode from datetime import datetime from hashlib import sha1 from unittest.mock import patch import lxml from freezegun import freeze_time from odoo import Command, fields from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.exceptions import UserError from odoo.tests import tagged from odoo.tools import file_open _logger = logging.getLogger(__name__) # Used to patch the computation of `is_valid` so that a certificate is # always valid regardless of the start and end date set on it. def _compute_is_valid(self): for cert in self: cert.is_valid = True @tagged('post_install_l10n', 'post_install', '-at_install') @patch('odoo.addons.certificate.models.certificate.Certificate._compute_is_valid', _compute_is_valid) class TestEdiFacturaeXmls(AccountTestInvoicingCommon): @classmethod @AccountTestInvoicingCommon.setup_country('es') def setUpClass(cls): super().setUpClass() cls.frozen_today = datetime(year=2023, month=1, day=1, hour=0, minute=0, second=0) # ==== Companies ==== cls.company_data['company'].write({ # -> PersonTypeCode 'J' 'street': "C. de Embajadores, 68-116", 'state_id': cls.env.ref('base.state_es_m').id, 'city': "Madrid", 'zip': "12345", 'vat': 'ES59962470K', }) cls.caixabank = cls.env['res.bank'].create({ 'name': 'CAIXABANK', 'bic': 'CAIXESBBXXX', }) cls.env['res.partner.bank'].create({ 'acc_number': 'ES9121000418450200051332', 'partner_id': cls.company_data['company'].partner_id.id, 'bank_id': cls.caixabank.id, 'acc_type': 'iban', }) # ==== Business ==== cls.partner_a.write({ # -> PersonTypeCode 'F' 'country_id': cls.env.ref('base.be').id, # -> ResidenceTypeCode 'U' 'vat': 'BE0477472701', 'city': "Namur", 'street': "Rue de Bruxelles, 15000", 'zip': "5000", 'invoice_edi_format': 'es_facturae', }) cls.partner_b.write({ 'name': 'Ayuntamiento de San Sebastián de los Reyes', 'is_company': True, 'country_id': cls.env.ref('base.es').id, 'vat': 'P2813400E', 'city': 'San Sebastián de los Reyes', 'street': 'Plaza de la Constitución, 1', 'zip': '28701', 'state_id': cls.env.ref('base.state_es_m').id, }) partner_b_ac = cls.partner_b.copy() partner_b_ac.write({ 'type': 'facturae_ac', 'parent_id': cls.partner_b.id, 'name': 'Intervención Municipal', 'l10n_es_edi_facturae_ac_center_code': 'L01281343', 'l10n_es_edi_facturae_ac_role_type_ids': [ Command.link(cls.env.ref('l10n_es_edi_facturae.ac_role_type_01').id), Command.link(cls.env.ref('l10n_es_edi_facturae.ac_role_type_02').id), Command.link(cls.env.ref('l10n_es_edi_facturae.ac_role_type_03').id), ], }) cls.partner_us = cls.env['res.partner'].create({ 'name': 'Indigo Exterior', 'city': 'Fremont', 'zip': '94538', 'country_id': cls.env.ref('base.us').id, 'state_id': cls.env['res.country.state'].search([('name', '=', 'California')]).id, 'email': 'indigo.exterior@example.com', 'company_type': 'company', 'is_company': True, }) cls.password = "test" cls.certificate_module = "odoo.addons.certificate.models.certificate" cls.move_module = "odoo.addons.l10n_es_edi_facturae.models.account_move" with freeze_time(cls.frozen_today), patch(f"{cls.certificate_module}.fields.datetime.now", lambda x=None: cls.frozen_today): cls.certificate = cls.env["certificate.certificate"].create({ 'name': 'Test ES certificate', 'content': b64encode(file_open('l10n_es_edi_facturae/tests/data/certificate_test.pfx', 'rb').read()), 'pkcs12_password': 'test', 'company_id': cls.company_data['company'].id, 'scope': 'facturae', }) cls.tax, cls.tax_2 = cls.env['account.tax'].create([{ 'name': "IVA 21% (Bienes)", 'company_id': cls.company_data['company'].id, 'amount': 21.0, 'price_include_override': 'tax_excluded', 'l10n_es_edi_facturae_tax_type': '01' }, { 'name': "IVA 21% (Bienes) Included", 'company_id': cls.company_data['company'].id, 'amount': 21.0, 'price_include_override': 'tax_included', 'l10n_es_edi_facturae_tax_type': '01' } ]) cls.nsmap = { 'ds': "http://www.w3.org/2000/09/xmldsig#", 'fac': "http://www.facturae.es/Facturae/2007/v3.1/Facturae", 'xades': "http://uri.etsi.org/01903/v1.3.2#", 'xd': "http://www.w3.org/2000/09/xmldsig#", } cls.maxDiff = None @classmethod def create_invoice(cls, **kwargs): return cls.env['account.move'].with_context(edi_test_mode=True).create({ 'partner_id': cls.partner_a.id, 'invoice_date': cls.frozen_today.isoformat(), 'date': cls.frozen_today.isoformat(), **kwargs, 'invoice_line_ids': [ Command.create({'product_id': cls.product_a.id, 'price_unit': 1000.0, **line_vals, }) for line_vals in kwargs.get('invoice_line_ids', []) ], }) def create_send_and_print(self, invoices, **kwargs): wizard_model = 'account.move.send.wizard' if len(invoices) == 1 else 'account.move.send.batch.wizard' return self.env[wizard_model]\ .with_context(active_model='account.move', active_ids=invoices.ids)\ .create(kwargs) def test_generate_signed_xml(self, date=None): random.seed(42) date = date or self.frozen_today # We need to patch dates and uuid to ensure the signature's consistency with freeze_time(date), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: date), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[ {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, {'price_unit': 242.0, 'tax_ids': [self.tax_2.id]}, {'price_unit': 1000.0, "discount": 10, "tax_ids": [self.tax.id]}, {'price_unit': 1210.0, "discount": -10, "tax_ids": [self.tax_2.id]}, ], ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open("l10n_es_edi_facturae/tests/data/expected_signed_document.xml", "rt") as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) def test_cannot_generate_unsigned_xml(self): """ Test that no valid certificate prevents a xml generation""" def _compute_is_valid(self): for cert in self: cert.is_valid = False random.seed(42) with freeze_time(self.frozen_today), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: self.frozen_today), \ patch('odoo.addons.certificate.models.certificate.Certificate._compute_is_valid', _compute_is_valid), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): invoice = self.create_invoice(partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [self.tax.id]}]) invoice.action_post() wizard = self.create_send_and_print(invoice) with self.assertRaises(UserError): wizard.action_send_and_print() def test_no_certificate_facturae_not_selected(self): self.certificate.unlink() invoice = self.create_invoice(partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [self.tax.id]}]) invoice.action_post() wizard = self.create_send_and_print(invoice) wizard.action_send_and_print() self.assertFalse(invoice.l10n_es_edi_facturae_xml_id) def test_tax_withheld(self): with freeze_time(self.frozen_today), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: self.frozen_today), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): witholding_taxes = self.env["account.tax"].create([{ 'name': "IVA 21%", 'company_id': self.company_data['company'].id, 'amount': 21.0, 'price_include_override': 'tax_excluded', 'l10n_es_edi_facturae_tax_type': '01' }, { 'name': "IVA 21% withholding", 'company_id': self.company_data['company'].id, 'amount': -21.0, 'price_include_override': 'tax_excluded', 'l10n_es_edi_facturae_tax_type': '01' }]) invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[ {'price_unit': 100.0, 'tax_ids': witholding_taxes.ids}, {'price_unit': 100.0, 'tax_ids': witholding_taxes.ids}, {'price_unit': 200.0, 'tax_ids': witholding_taxes.ids}, ], ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open("l10n_es_edi_facturae/tests/data/expected_tax_withholding.xml", "rt") as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) def test_in_invoice(self): random.seed(42) # We need to patch dates and uuid to ensure the signature's consistency with freeze_time(self.frozen_today), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: self.frozen_today), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='in_invoice', invoice_line_ids=[ {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, {'price_unit': 242.0, 'tax_ids': [self.tax_2.id]}, {'price_unit': 1000.0, "discount": 10, "tax_ids": [self.tax.id]}, {'price_unit': 1000.0, "discount": -10, "tax_ids": [self.tax.id]}, ], ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open("l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml", "rt") as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) def test_refund_invoice(self): random.seed(42) # We need to patch dates and uuid to ensure the signature's consistency with freeze_time(self.frozen_today), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: self.frozen_today), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[ {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, {'price_unit': 100.0, 'tax_ids': [self.tax.id]}, ], ) invoice.action_post() reversal_wizard = self.env['account.move.reversal'].create({ 'move_ids': invoice.ids, 'journal_id': invoice.journal_id.id, 'date': self.frozen_today, 'company_id': self.company_data['company'].id, 'l10n_es_edi_facturae_reason_code': '01' }) reversal_wizard.modify_moves() refund = invoice.reversal_move_ids refund.ref = 'ABCD-2023-001' generated_file, errors = refund._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open("l10n_es_edi_facturae/tests/data/expected_refund_document.xml", "rt") as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) def test_discount_100_percent(self): """ Create an invoice with a 100% discount """ with freeze_time(self.frozen_today), \ patch(f"{self.certificate_module}.fields.datetime.now", lambda x=None: self.frozen_today), \ patch(f"{self.move_module}.sha1", lambda x: sha1()): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[{'product_id': self.product_a.id, 'price_unit': 1000.0, 'discount': 100.0, 'quantity': 2}], ) invoice.action_post() wizard = self.create_send_and_print(invoice) result = wizard.action_send_and_print() self.assertEqual(result['type'], 'ir.actions.act_url') self.assertEqual(invoice.invoice_line_ids[0].price_subtotal, 0.0) def test_import_multiple_invoices(self): with file_open("l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml", "rt") as f: imported_xml = lxml.etree.fromstring(f.read().encode()) moves = self.env['account.move'].create({'move_type': 'out_invoice'}) moves._import_invoice_facturae(moves, {'xml_tree': imported_xml}) moves += self.env['account.move'].search([('ref', '=', 'INV/2023/00006'), ('company_id', '=', self.company_data['company'].id)], limit=1) currency = self.env['res.currency'].search([('name', '=', 'EUR')]) self.assertRecordValues(moves, [ { 'partner_id': self.partner_us.id, 'amount_total': 2186.20, 'amount_untaxed': 2119.0, 'amount_tax': 67.2, 'move_type': 'out_invoice', 'currency_id': currency.id, 'invoice_date': fields.Date.from_string('2023-08-01'), 'invoice_date_due': fields.Date.from_string('2023-08-31'), 'ref': 'INV/2023/00005', 'narration': '
Terms and conditions.
', }, { 'partner_id': self.partner_us.id, 'amount_total': 1161.60, 'amount_untaxed': 960.0, 'amount_tax': 201.60, 'move_type': 'out_invoice', 'currency_id': currency.id, 'invoice_date': fields.Date.from_string('2023-07-01'), 'invoice_date_due': fields.Date.from_string('2023-07-31'), 'ref': 'INV/2023/00006', 'narration': 'Legal References.
', }, ]) # Check first invoice's lines. self.assertRecordValues(moves[0].invoice_line_ids, [ { 'name': '[E-COM07] Large Cabinet', 'price_unit': 320.0, 'quantity': 1.0, 'price_total': 387.2, 'discount': 0.0, }, { 'name': '[E-COM09] Large Desk', 'price_unit': 1799.0, 'quantity': 1.0, 'price_total': 1799, 'discount': 0.0, } ]) # Check second invoice's lines. self.assertRecordValues(moves[1].invoice_line_ids, [ { 'name': '[E-COM07] Large Cabinet', 'price_unit': 320.0, 'quantity': 3.0, 'price_total': 1161.60, 'discount': 0.0, }, ]) def test_import_withheld_taxes(self): with file_open("l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml", "rt") as f: imported_xml = lxml.etree.fromstring(f.read().encode()) move = self.env['account.move'].create({'move_type': 'out_invoice'}) move._import_invoice_facturae(move, {'xml_tree': imported_xml}) self.assertRecordValues(move, [ { 'amount_total': 323.2, 'amount_untaxed': 320.0, 'amount_tax': 3.2, }, ]) tax_amounts = [tax.amount for tax in move.invoice_line_ids.tax_ids] # Check first invoice's lines. self.assertEqual(tax_amounts, [21.0, -20.0]) @freeze_time('2023-01-01') def test_generate_with_administrative_centers(self): invoice = self.create_invoice( partner_id=self.partner_b.id, move_type='out_invoice', invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [self.tax.id]},] ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open('l10n_es_edi_facturae/tests/data/expected_ac_document.xml', 'rt') as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) @freeze_time('2023-01-01') def test_generate_with_invoice_period(self): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [self.tax.id]}], l10n_es_invoicing_period_start_date='2023-01-01', l10n_es_invoicing_period_end_date='2023-01-31', ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open('l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml', 'rt') as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml) @freeze_time('2023-01-01') def test_generate_with_payment_means(self): invoice = self.create_invoice( partner_id=self.partner_a.id, move_type='out_invoice', invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [self.tax.id]}], l10n_es_payment_means='14', ) invoice.action_post() generated_file, errors = invoice._l10n_es_edi_facturae_render_facturae() self.assertFalse(errors) self.assertTrue(generated_file) with file_open('l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml', 'rt') as f: expected_xml = lxml.etree.fromstring(f.read().encode()) self.assertXmlTreeEqual(lxml.etree.fromstring(generated_file), expected_xml)