diff --git a/l10n_es_aeat_verifactu/__manifest__.py b/l10n_es_aeat_verifactu/__manifest__.py index fbabed4eb9f..509982f39e8 100644 --- a/l10n_es_aeat_verifactu/__manifest__.py +++ b/l10n_es_aeat_verifactu/__manifest__.py @@ -17,6 +17,7 @@ "l10n_es_aeat", "account_invoice_refund_link", "queue_job", + "account", ], "data": [ "data/aeat_verifactu_tax_agency_data.xml", @@ -32,5 +33,6 @@ "views/aeat_verifactu_map_view.xml", "views/aeat_verifactu_map_lines_view.xml", "views/aeat_verifactu_registration_keys_view.xml", + "views/report_invoice.xml", ], } diff --git a/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml index c63852a39f6..b3ecf8e7b81 100644 --- a/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml @@ -9,5 +9,7 @@ https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP + https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR + https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR diff --git a/l10n_es_aeat_verifactu/models/account_move.py b/l10n_es_aeat_verifactu/models/account_move.py index 0e1cb0bca18..a4da671077d 100644 --- a/l10n_es_aeat_verifactu/models/account_move.py +++ b/l10n_es_aeat_verifactu/models/account_move.py @@ -3,6 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import pytz +from collections import OrderedDict from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -377,5 +378,18 @@ def _get_receiver_dict(self): } } + def _get_verifactu_qr_values(self): + """Get the QR values for the verifactu""" + self.ensure_one() + company_vat = self.company_id.partner_id._parse_aeat_vat_info()[2] + return OrderedDict( + [ + ("nif", company_vat), + ("numserie", self.name), + ("fecha", self.invoice_date.strftime("%d-%m-%Y")), + ("importe", self.amount_total), + ] + ) + def cancel_verifactu(self): raise NotImplementedError diff --git a/l10n_es_aeat_verifactu/models/aeat_tax_agency.py b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py index adbc1b91a0b..dbf95e6bcc1 100644 --- a/l10n_es_aeat_verifactu/models/aeat_tax_agency.py +++ b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py @@ -21,6 +21,8 @@ class AeatTaxAgency(models.Model): verifactu_wsdl_out_test_address = fields.Char( string="SuministroInformacion Test Address" ) + verifactu_qr_base_url = fields.Char(string="QR Base URL") + verifactu_qr_base_url_test_address = fields.Char(string="QR Base URL Test") def _connect_params_verifactu(self, mapping_key, company): self.ensure_one() diff --git a/l10n_es_aeat_verifactu/models/verifactu_mixin.py b/l10n_es_aeat_verifactu/models/verifactu_mixin.py index dfa921a4b68..3520c8e5334 100644 --- a/l10n_es_aeat_verifactu/models/verifactu_mixin.py +++ b/l10n_es_aeat_verifactu/models/verifactu_mixin.py @@ -2,9 +2,12 @@ # Copyright 2024 Aures TIC - Almudena de La Puente # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 +import io import json import logging from hashlib import sha256 +from urllib.parse import urlencode from requests import Session @@ -15,6 +18,14 @@ from odoo.addons.l10n_es_aeat.models.aeat_mixin import round_by_keys +_logger = logging.getLogger(__name__) + +try: + import qrcode +except (ImportError, IOError) as err: + qrcode = None + _logger.error(err) + ########################################### # revisar los imports que no hagan falta # cuando funcione bien el _connect_aeat sin tener que poner @@ -85,6 +96,8 @@ class VerifactuMixin(models.AbstractModel): readonly=True, string="Verifactu Code", ) + verifactu_qr_url = fields.Char("URL", compute="_compute_verifactu_qr_url") + verifactu_qr = fields.Binary(string="QR", compute="_compute_verifactu_qr") def _compute_verifactu_enabled(self): raise NotImplementedError @@ -100,6 +113,56 @@ def _compute_verifactu_macrodata(self): >= 0 ) + def _compute_verifactu_qr_url(self): + """Returns the URL to be used in the QR code. A sample URL would be (urlencoded): + https://prewww2.aeat.es/wlpl/TIKECONT/ValidarQR?nif=89890001K&numserie=12345678%26G33&fecha=01-01-2024&importe=241.4 + """ + for record in self: + agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain") + if record.company_id.verifactu_test: + qr_base_url = agency.verifactu_qr_base_url_test_address + else: + qr_base_url = agency.verifactu_qr_base_url + + qr_values = record._get_verifactu_qr_values() + + # Check all values are ASCII between 32 and 126 + for value in qr_values.values(): + try: + str(value).encode("ascii") + except UnicodeEncodeError: + raise UserError(_("QR URL value '{}' is not ASCII").format(value)) + + # Build QR URL + qr_url = "{}?{}".format( + qr_base_url, + urlencode(qr_values, encoding="utf-8"), + ) + + record.verifactu_qr_url = qr_url + + def _compute_verifactu_qr(self): + # If qrcode module is not available, we can't generate QR codes + if not qrcode: + _logger.error("qrcode module is not available") + return + for record in self: + if record.state != "posted" or not record.verifactu_enabled: + record.verifactu_qr = False + continue + qr = qrcode.QRCode( + border=0, error_correction=qrcode.constants.ERROR_CORRECT_M + ) + qr.add_data(record.verifactu_qr_url) + qr.make() + img = qr.make_image() + with io.BytesIO() as temp: + img.save(temp, format="PNG") + record.verifactu_qr = base64.b64encode(temp.getvalue()) + + def _get_verifactu_qr_values(self): + raise NotImplementedError + @api.model def _get_verifactu_tax_keys(self): return self.env["account.fiscal.position"]._get_verifactu_tax_keys() diff --git a/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst b/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst index 29280226b91..13ba0bcd75c 100644 --- a/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst +++ b/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst @@ -2,3 +2,4 @@ * Almudena de La Puente * Laura Cazorla * Andreu Orensanz +* Iván Antón diff --git a/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py b/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py index e0797cf8316..a0b50a61ee6 100644 --- a/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py +++ b/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py @@ -3,6 +3,7 @@ import json from hashlib import sha256 +from urllib.parse import urlparse, parse_qs from odoo.modules.module import get_resource_path @@ -196,3 +197,103 @@ def test_get_verifactu_invoice_data(self): for inv_type, lines, extra_vals in mapping: self._create_and_test_invoice_verifactu_dict(inv_type, lines, extra_vals) return + + +class TestL10nEsAeatVerifactuQR(TestL10nEsAeatVerifactuBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def _get_required_qr_params(self): + """Helper to generate the required QR code parameters.""" + return { + "nif": self.invoice.company_id.partner_id._parse_aeat_vat_info()[2], + "numserie": self.invoice.name, + "fecha": self.invoice._change_date_format(self.invoice.invoice_date), + "importe": self.invoice.amount_total, + } + + def test_verifactu_qr_generation(self): + """ + Test the generation of the QR code image for the invoice. + """ + self.invoice.action_post() + qr_code = self.invoice.verifactu_qr + + self.assertTrue(qr_code, "QR code should be generated for the invoice.") + self.assertIsInstance(qr_code, bytes, "QR code should be in bytes format.") + + def test_verifactu_qr_url_format(self): + """ + Test the format of the generated QR URL to ensure it meets expected criteria. + """ + self.invoice.action_post() + qr_url = self.invoice.verifactu_qr_url + + self.assertTrue(qr_url, "QR URL should be generated for the invoice.") + + test_url = self.env.ref( + "l10n_es_aeat.aeat_tax_agency_spain" + ).verifactu_qr_base_url_test_address + self.assertTrue(test_url, "Test URL should not be empty.") + + parsed_url = urlparse(qr_url) + actual_params = parse_qs(parsed_url.query) + + expected_params = self._get_required_qr_params() + for key, expected_value in expected_params.items(): + self.assertIn( + key, actual_params, f"QR URL should contain the parameter: {key}" + ) + self.assertEqual( + actual_params[key][0], + str(expected_value), + f"QR URL parameter '{key}' should have value '{expected_value}', got '{actual_params[key][0]}' instead.", + ) + + def test_verifactu_qr_code_generation_on_draft(self): + """ + Ensure that the QR code is not generated for invoices in draft state. + """ + qr_code = self.invoice.verifactu_qr + self.assertFalse(qr_code, "QR code should not be generated for draft invoices.") + + def test_verifactu_qr_code_after_update(self): + """ + Test that the QR code is regenerated if the invoice details are updated. + """ + self.invoice.action_post() + original_qr_code = self.invoice.verifactu_qr + + self.invoice.button_cancel() + + self.invoice.button_draft() + + self.invoice.write( + { + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "account_id": self.account_expense.id, + "name": "Updated line", + "price_unit": 200, + "quantity": 1, + }, + ) + ] + } + ) + self.invoice.action_post() + + self.invoice.invalidate_model(["verifactu_qr_url", "verifactu_qr"]) + + updated_qr_code = self.invoice.verifactu_qr + + self.assertNotEqual( + original_qr_code, + updated_qr_code, + "QR code should be regenerated after invoice update.", + ) diff --git a/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml b/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml index 1f790731475..2c0ca0e5416 100644 --- a/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml +++ b/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml @@ -17,6 +17,13 @@ /> + + + + diff --git a/l10n_es_aeat_verifactu/views/report_invoice.xml b/l10n_es_aeat_verifactu/views/report_invoice.xml new file mode 100644 index 00000000000..910cd124107 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/report_invoice.xml @@ -0,0 +1,17 @@ + + + +