From eaa976697479634d9263a62d37c27b93741e9a5b Mon Sep 17 00:00:00 2001 From: "Luis J. Salvatierra" Date: Sun, 9 Feb 2025 12:47:39 +0100 Subject: [PATCH] [ADD] l10n_es_aeat_verifactu_pos --- l10n_es_aeat_verifactu_pos/README.rst | 86 ++++ l10n_es_aeat_verifactu_pos/__init__.py | 1 + l10n_es_aeat_verifactu_pos/__manifest__.py | 26 ++ l10n_es_aeat_verifactu_pos/models/__init__.py | 4 + .../models/account_move.py | 22 + .../models/pos_config.py | 28 ++ .../models/pos_order.py | 400 ++++++++++++++++ .../models/pos_session.py | 14 + .../readme/CONTRIBUTORS.rst | 2 + .../readme/DESCRIPTION.rst | 1 + l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst | 5 + .../static/description/index.html | 431 ++++++++++++++++++ .../static/src/css/pos_receipts.css | 6 + .../static/src/js/models.js | 60 +++ .../static/src/xml/OrderReceipt.xml | 23 + l10n_es_aeat_verifactu_pos/tests/__init__.py | 1 + .../tests/test_l10n_es_aeat_verifactu_pos.py | 384 ++++++++++++++++ .../views/pos_order_view.xml | 86 ++++ .../odoo/addons/l10n_es_aeat_verifactu_pos | 1 + setup/l10n_es_aeat_verifactu_pos/setup.py | 6 + 20 files changed, 1587 insertions(+) create mode 100644 l10n_es_aeat_verifactu_pos/README.rst create mode 100644 l10n_es_aeat_verifactu_pos/__init__.py create mode 100644 l10n_es_aeat_verifactu_pos/__manifest__.py create mode 100644 l10n_es_aeat_verifactu_pos/models/__init__.py create mode 100644 l10n_es_aeat_verifactu_pos/models/account_move.py create mode 100644 l10n_es_aeat_verifactu_pos/models/pos_config.py create mode 100644 l10n_es_aeat_verifactu_pos/models/pos_order.py create mode 100644 l10n_es_aeat_verifactu_pos/models/pos_session.py create mode 100644 l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst create mode 100644 l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst create mode 100644 l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst create mode 100644 l10n_es_aeat_verifactu_pos/static/description/index.html create mode 100644 l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css create mode 100644 l10n_es_aeat_verifactu_pos/static/src/js/models.js create mode 100644 l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml create mode 100644 l10n_es_aeat_verifactu_pos/tests/__init__.py create mode 100644 l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py create mode 100644 l10n_es_aeat_verifactu_pos/views/pos_order_view.xml create mode 120000 setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos create mode 100644 setup/l10n_es_aeat_verifactu_pos/setup.py diff --git a/l10n_es_aeat_verifactu_pos/README.rst b/l10n_es_aeat_verifactu_pos/README.rst new file mode 100644 index 00000000000..0cff04a7177 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/README.rst @@ -0,0 +1,86 @@ +============================ +Comunicación Veri*FACTU: TPV +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:81d39ea57c7a9f59a4d558c12bff79c2f5bfb4196a14da3cd3a42f8838f7b750 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--spain-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-spain/tree/16.0/l10n_es_aeat_verifactu_pos + :alt: OCA/l10n-spain +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-spain-16-0/l10n-spain-16-0-l10n_es_aeat_verifactu_pos + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-spain&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Módulo para la presentación inmediata de la facturación desde TPV. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +* Refactor `retry` strategy when database is locked trying to obtain the last verifactu invoice from PoS config +* Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain +* Implement cancelling simplified and complete invoices from the PoS +* Multiple devices per PoS Config (l10n_es_pos_by_device) +* Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Factor Libre S.L. + +Contributors +~~~~~~~~~~~~ + +* Luis J. Salvatierra + + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/l10n-spain `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_es_aeat_verifactu_pos/__init__.py b/l10n_es_aeat_verifactu_pos/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_es_aeat_verifactu_pos/__manifest__.py b/l10n_es_aeat_verifactu_pos/__manifest__.py new file mode 100644 index 00000000000..92106ca31b8 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Comunicación Veri*FACTU: TPV", + "version": "16.0.1.0.0", + "category": "Accounting & Finance", + "website": "https://github.com/OCA/l10n-spain", + "author": "Factor Libre S.L., Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "point_of_sale", + "l10n_es_pos", + "l10n_es_aeat_verifactu", + "pos_default_partner", + ], + "assets": { + "point_of_sale.assets": [ + "l10n_es_aeat_verifactu_pos/static/src/js/models.js", + "l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml", + "l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css", + ], + }, + "data": [ + "views/pos_order_view.xml", + ], +} diff --git a/l10n_es_aeat_verifactu_pos/models/__init__.py b/l10n_es_aeat_verifactu_pos/models/__init__.py new file mode 100644 index 00000000000..d5de4ae842a --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/__init__.py @@ -0,0 +1,4 @@ +from . import pos_session +from . import pos_config +from . import pos_order +from . import account_move diff --git a/l10n_es_aeat_verifactu_pos/models/account_move.py b/l10n_es_aeat_verifactu_pos/models/account_move.py new file mode 100644 index 00000000000..f9185019b3e --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/account_move.py @@ -0,0 +1,22 @@ +from odoo import _, exceptions, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _get_chaining_invoice_dict(self): + """PoS Config has a different chain for VERI*FACTU""" + if self.pos_order_ids and len(self.pos_order_ids.ids) == 1: + # TODO: use cases + # * The Invoice is created from the PoS + # * The Invoice is created later after some time, in this case + # the PoS order (simplified invoice) is already in the chain + pos_order = self.pos_order_ids + return pos_order._get_chaining_invoice_dict() + elif len(self.pos_order_ids) > 1: + # TODO: is possible to have multiple PoS orders for the same Invoice? + raise exceptions.UserError( + _("VERI*FACTU: multiple PoS Orders not supported") + ) + else: + return super()._get_chaining_invoice_dict() diff --git a/l10n_es_aeat_verifactu_pos/models/pos_config.py b/l10n_es_aeat_verifactu_pos/models/pos_config.py new file mode 100644 index 00000000000..7b379f7c0a5 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_config.py @@ -0,0 +1,28 @@ +from odoo import api, fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + verifactu_last_invoice_id = fields.Many2one( + string="Last veri*FACTU Order sent", + comodel_name="pos.order", + copy=False, + help="Last POS order sent to veri*FACTU system for chaining purposes", + ) + verifactu_base_url = fields.Char( + string="Verifactu Base URL", + compute="_compute_verifactu_base_url", + store=True, + help="Base URL for Verifactu QR code generation. Needed on PoS.", + ) + + @api.depends("company_id.verifactu_test") + def _compute_verifactu_base_url(self): + for record in self: + agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain") + record.verifactu_base_url = ( + agency.verifactu_qr_base_url_test_address + if record.company_id.verifactu_test + else agency.verifactu_qr_base_url + ) diff --git a/l10n_es_aeat_verifactu_pos/models/pos_order.py b/l10n_es_aeat_verifactu_pos/models/pos_order.py new file mode 100644 index 00000000000..2ff79a60d36 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_order.py @@ -0,0 +1,400 @@ +import logging +from collections import OrderedDict +from time import sleep + +import pytz +from psycopg2 import OperationalError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +VERIFACTU_VALID_POS_STATES = [ + "paid", # paid is set on PoS order processing + "done", # done is set on PoS session validation (closing) +] + +# TODO: move to l10n_es_aeat_verifactu +SEND_TO_VERIFACTU_MAX_RETRIES = 5 + + +class PosOrder(models.Model): + _name = "pos.order" + _inherit = ["pos.order", "verifactu.mixin"] + + verifactu_previous_invoice_id = fields.Many2one( + string="Previous veri*FACTU Order sent", + comodel_name="pos.order", + copy=False, + ) + + @api.depends("amount_total") + def _compute_verifactu_macrodata(self): + return super()._compute_verifactu_macrodata() + + @api.depends( + "company_id", + "company_id.verifactu_enabled", + "fiscal_position_id", + "fiscal_position_id.aeat_active", + ) + def _compute_verifactu_enabled(self): + """Compute if the POS order is enabled for the veri*FACTU""" + for order in self: + if order.company_id.verifactu_enabled: + order.verifactu_enabled = ( + order.fiscal_position_id and order.fiscal_position_id.aeat_active + ) or not order.fiscal_position_id + else: + order.verifactu_enabled = False + + @api.model + def _process_order(self, order, draft, existing_order): + pos_order_id = super()._process_order(order, draft, existing_order) + pos_order = self.env["pos.order"].browse(pos_order_id) + + if not self._should_send_to_verifactu(pos_order): + return pos_order_id + + # TODO: review retry strategy + # possible scenarios: multiple devices registering invoices + # from the same PoS Config + for attempt in range(SEND_TO_VERIFACTU_MAX_RETRIES): + try: + pos_order.send_verifactu() + break + except OperationalError: + if attempt == SEND_TO_VERIFACTU_MAX_RETRIES - 1: + # TODO: should we have a stopping mechanism and avoid sending more + # invoices for this chain when it is no possible to obtain a lock + # on verifactu_last_invoice_id (pos.config)? + _logger.error( + "Failed to send order %s with ID %d to Verifactu after %d attempts", + pos_order.l10n_es_unique_id, + pos_order.id, + SEND_TO_VERIFACTU_MAX_RETRIES, + ) + else: + sleep(1) # Wait 1 second before next try + return pos_order_id + + def _should_send_to_verifactu(self, pos_order): + return ( + not config["test_enable"] + and pos_order.exists() + and not pos_order.to_invoice + and pos_order.verifactu_enabled + and pos_order.state in VERIFACTU_VALID_POS_STATES + ) + + def _get_verifactu_document_type(self): + return "F2" # Simplified invoice for POS orders + + def _get_verifactu_description(self): + return self.verifactu_description or self.company_id.verifactu_description + + def _get_document_date(self): + return self.date_order + + def _get_document_fiscal_date(self): + return self.date_order + + def _get_valid_document_states(self): + return VERIFACTU_VALID_POS_STATES + + def _get_document_serial_number(self): + return (self.l10n_es_unique_id or self.pos_reference)[0:60] + + def _get_mapping_key(self): + return "out_invoice" + + def _get_verifactu_issuer(self): + return self.company_id.partner_id._parse_aeat_vat_info()[2] + + def _get_verifactu_amount_tax(self): + return self.amount_tax + + def _get_verifactu_amount_total(self): + return self.amount_total + + def _get_verifactu_previous_hash(self): + return self.verifactu_previous_invoice_id.verifactu_hash + + def _get_verifactu_registration_date(self): + return ( + pytz.utc.localize(self.create_date) + .astimezone() + .isoformat(timespec="seconds") + ) + + def _get_verifactu_qr_values(self): + """Get the QR values for the verifactu""" + self.ensure_one() + return OrderedDict( + [ + ("nif", self._get_verifactu_issuer()), + ("numserie", self._get_document_serial_number()), + ( + "fecha", + self._change_date_format(self._get_document_fiscal_date()), + ), + ("importe", self._get_verifactu_amount_total()), + ] + ) + + def _get_verifactu_hash_string(self): + """Gets the verifactu hash string""" + if ( + not self.verifactu_enabled + or self.state not in VERIFACTU_VALID_POS_STATES + or self.is_invoiced + ): + return "" + issuerID = self._get_verifactu_issuer() + serialNumber = self._get_document_serial_number() + expeditionDate = self._change_date_format(self._get_document_date()) + documentType = self._get_verifactu_document_type() + amountTax = self._get_verifactu_amount_tax() + amountTotal = self._get_verifactu_amount_total() + previousHash = self._get_verifactu_previous_hash() + registrationDate = self._get_verifactu_registration_date() + verifactu_hash_string = ( + f"IDEmisorFactura={issuerID}&" + f"NumSerieFactura={serialNumber}&" + f"FechaExpedicionFactura={expeditionDate}&" + f"TipoFactura={documentType}&" + f"CuotaTotal={amountTax}&" + f"ImporteTotal={amountTotal}&" + f"Huella={previousHash}&" + f"FechaHoraHusoGenRegistro={registrationDate}" + ) + return verifactu_hash_string + + def _get_verifactu_invoice_dict_out(self, cancel=False): + """Build dict with data to send to AEAT WS for POS orders.""" + self.ensure_one() + document_date = self._change_date_format(self._get_document_date()) + company = self.company_id + serial_number = self._get_document_serial_number() + taxes_dict, amount_tax, amount_total = self._get_verifactu_taxes_and_total() + company_vat = company.partner_id._parse_aeat_vat_info()[2] + verifactu_doc_type = self._get_verifactu_document_type() + registroAlta = {} + inv_dict = { + "IDVersion": self._get_verifactu_version(), + "IDFactura": { + "IDEmisorFactura": company_vat, + "NumSerieFactura": serial_number, + "FechaExpedicionFactura": document_date, + }, + "NombreRazonEmisor": self.company_id.name[0:120], + "TipoFactura": verifactu_doc_type, + "DescripcionOperacion": self._get_verifactu_description(), + "Desglose": taxes_dict, + "CuotaTotal": amount_tax, + "ImporteTotal": amount_total, + "Encadenamiento": self._get_chaining_invoice_dict(), + "SistemaInformatico": self._get_verifactu_developer_dict(), + "FechaHoraHusoGenRegistro": self._get_verifactu_registration_date(), + "TipoHuella": "01", # SHA-256 + "Huella": self.verifactu_hash, + } + registroAlta.setdefault("RegistroAlta", inv_dict) + return registroAlta + + def _get_chaining_invoice_dict(self): + """Get the chaining invoice dictionary for POS orders""" + inv_dict = {} + try: + self.config_id.flush_model(["verifactu_last_invoice_id"]) + self._cr.execute( + "SELECT verifactu_last_invoice_id FROM" + " pos_config WHERE id = %s FOR UPDATE NOWAIT", + [self.config_id.id], + ) + result = self._cr.fetchone() + prev_order = self.env["pos.order"].browse(result[0]) if result else False + if prev_order and prev_order.exists(): + self.verifactu_previous_invoice_id = prev_order + if prev_order.is_invoiced and prev_order.account_move.exists(): + prev_inv = prev_order.account_move + else: + prev_inv = prev_order + inv_dict = { + "RegistroAnterior": { + "IDEmisorFactura": prev_inv._get_verifactu_issuer(), + "NumSerieFactura": prev_inv._get_document_serial_number(), + "FechaExpedicionFactura": prev_inv._change_date_format( + prev_inv._get_document_date() + ), + "Huella": prev_inv.verifactu_hash, + } + } + else: + inv_dict = {"PrimerRegistro": "S"} + self._cr.execute( + "UPDATE pos_config SET verifactu_last_invoice_id = %s WHERE id = %s", + (self.id, self.config_id.id), + ) + self.config_id.invalidate_recordset(["verifactu_last_invoice_id"]) + except OperationalError: + _logger.error( + "VERI*FACTU: Could not obtain lock for PoS Config %s " + "and order %s with ID %d", + self.config_id.id, + self.l10n_es_unique_id, + self.id, + ) + raise + return inv_dict + + def _get_verifactu_taxes_and_total(self): + """Get the tax breakdown for Verifactu from POS order lines. + + Returns: + tuple: (taxes_dict, amount_tax, amount_total) where: + - taxes_dict: Dictionary with tax breakdown + - amount_tax: Total tax amount + - amount_total: Total amount with taxes + """ + self.ensure_one() + taxes_dict = {} + taxes_dict.setdefault("DetalleDesglose", []) + + # Get tax lines from POS order + tax_lines = {} + for line in self.lines: + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_ids_after_fiscal_position.compute_all( + price, + self.pricelist_id.currency_id, + line.qty, + product=line.product_id, + partner=self.partner_id or False, + ) + + for tax_vals in taxes["taxes"]: + tax = self.env["account.tax"].browse(tax_vals["id"]) + if tax not in tax_lines: + tax_lines[tax] = { + "tax": tax, + "base": tax_vals["base"], + "amount": tax_vals["amount"], + } + else: + tax_lines[tax]["base"] += tax_vals["base"] + tax_lines[tax]["amount"] += tax_vals["amount"] + + # Get tax mappings + document_date = self._get_document_fiscal_date() + taxes_S1 = self._get_verifactu_taxes_map(["S1"], document_date) + taxes_S2 = self._get_verifactu_taxes_map(["S2"], document_date) + taxes_N1 = self._get_verifactu_taxes_map(["N1"], document_date) + taxes_N2 = self._get_verifactu_taxes_map(["N2"], document_date) + breakdown_taxes = taxes_S1 + taxes_S2 + taxes_N1 + taxes_N2 + + # Build tax breakdown + for tax_line in tax_lines.values(): + tax = tax_line["tax"] + if tax in breakdown_taxes: + operation_type = self._get_operation_type( + tax_line, taxes_S1, taxes_S2, taxes_N1, taxes_N2 + ) + tax_dict = { + "Impuesto": self.verifactu_tax_key, + "ClaveRegimen": self.verifactu_registration_key_code, + "CalificacionOperacion": operation_type, + } + tax_dict.update(self._get_verifactu_tax_dict(tax_line, tax_lines)) + taxes_dict["DetalleDesglose"].append(tax_dict) + + return ( + taxes_dict, + self._get_verifactu_amount_tax(), + self._get_verifactu_amount_total(), + ) + + def _get_verifactu_tax_dict(self, tax_line, tax_lines): + """Get the Verifactu tax dictionary for the passed tax line. + + Args: + tax_line (dict): Tax line being analyzed + tax_lines (dict): Dictionary of processed taxes + + Returns: + dict: Verifactu tax values + """ + tax = tax_line["tax"] + tax_base_amount = tax_line["base"] + if tax.amount_type == "group": + tax_type = abs(tax.children_tax_ids.filtered("amount")[:1].amount) + else: + tax_type = abs(tax.amount) + + tax_dict = { + "TipoImpositivo": str(tax_type), + "BaseImponibleOimporteNoSujeto": tax_base_amount, + "CuotaRepercutida": tax_line["amount"], + } + + # Recargo de equivalencia + req_tax = self._get_verifactu_tax_req(tax) + if req_tax: + tax_dict["TipoRecargoEquivalencia"] = req_tax.amount + tax_dict["CuotaRecargoEquivalencia"] = tax_lines[req_tax]["amount"] + + return tax_dict + + def _get_verifactu_tax_req(self, tax): + """Get the associated req tax for the specified tax. + + Args: + tax: Initial tax for searching RE linked tax + + Returns: + account.tax: REQ tax linked to provided tax + + Raises: + UserError: If there's a mismatch in RE taxes + """ + self.ensure_one() + document_date = self._get_document_fiscal_date() + taxes_req = self._get_verifactu_taxes_map(["RE"], document_date) + + re_lines = self.lines.filtered( + lambda x: tax in x.tax_ids and x.tax_ids & taxes_req + ) + req_tax = re_lines.mapped("tax_ids") & taxes_req + + if len(req_tax) > 1: + raise UserError(_("There's a mismatch in taxes for RE. Check them.")) + return req_tax + + def _get_operation_type(self, tax_line, taxes_S1, taxes_S2, taxes_N1, taxes_N2): + """Get the operation type for Verifactu based on tax configuration. + + Args: + tax_line (dict): Tax line info + taxes_S1: Taxes for type S1 + taxes_S2: Taxes for type S2 + taxes_N1: Taxes for type N1 + taxes_N2: Taxes for type N2 + + Returns: + str: Operation type code (S1, S2, N1, N2) + """ + tax = tax_line["tax"] + if tax in taxes_S1: + return "S1" + elif tax in taxes_S2: + return "S2" + elif tax in taxes_N1: + return "N1" + elif tax in taxes_N2: + return "N2" + return "S1" + + def cancel_verifactu(self): + raise NotImplementedError diff --git a/l10n_es_aeat_verifactu_pos/models/pos_session.py b/l10n_es_aeat_verifactu_pos/models/pos_session.py new file mode 100644 index 00000000000..f53d4829f43 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_session.py @@ -0,0 +1,14 @@ +from odoo import models + + +class PosSession(models.Model): + _inherit = "pos.session" + + def _loader_params_res_company(self): + params = super()._loader_params_res_company() + params["search_params"]["fields"].extend( + [ + "verifactu_enabled", + ] + ) + return params diff --git a/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst b/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..0928ebdc901 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Luis J. Salvatierra + diff --git a/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst b/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..ca6fa68a854 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Módulo para la presentación inmediata de la facturación desde TPV. diff --git a/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst b/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst new file mode 100644 index 00000000000..cc0a2a92ded --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +* Refactor `retry` strategy when database is locked trying to obtain the last verifactu invoice from PoS config +* Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain +* Implement cancelling simplified and complete invoices from the PoS +* Multiple devices per PoS Config (l10n_es_pos_by_device) +* Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete. diff --git a/l10n_es_aeat_verifactu_pos/static/description/index.html b/l10n_es_aeat_verifactu_pos/static/description/index.html new file mode 100644 index 00000000000..90b40eaa925 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/description/index.html @@ -0,0 +1,431 @@ + + + + + +Comunicación Veri*FACTU: TPV + + + +
+

Comunicación Veri*FACTU: TPV

+ + +

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

+

Módulo para la presentación inmediata de la facturación desde TPV.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Refactor retry strategy when database is locked trying to obtain the last verifactu invoice from PoS config
  • +
  • Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain
  • +
  • Implement cancelling simplified and complete invoices from the PoS
  • +
  • Multiple devices per PoS Config (l10n_es_pos_by_device)
  • +
  • Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Factor Libre S.L.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/l10n-spain project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css b/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css new file mode 100644 index 00000000000..55d0de67dda --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css @@ -0,0 +1,6 @@ +.pos-receipt .verifactu-qr { + display: block; + margin: 8px auto; + width: 40mm; + height: 40mm; +} diff --git a/l10n_es_aeat_verifactu_pos/static/src/js/models.js b/l10n_es_aeat_verifactu_pos/static/src/js/models.js new file mode 100644 index 00000000000..29f564f7fbd --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/js/models.js @@ -0,0 +1,60 @@ +odoo.define("l10n_es_aeat_verifactu_pos.models", function (require) { + "use strict"; + + const {Order} = require("point_of_sale.models"); + const Registries = require("point_of_sale.Registries"); + + const VerifactuOrder = (Order) => + class VerifactuOrder extends Order { + export_for_printing() { + const result = super.export_for_printing(...arguments); + result.verifactu_qr = + this.finalized && this._get_verifactu_qr_code_data(); + return result; + } + + _build_verifactu_qr_url() { + const baseUrl = this.pos.config.verifactu_base_url; + const company_partner = this.pos.db.get_partner_by_id( + this.pos.company.partner_id[0] + ); + const vatNumber = company_partner.aeat_identification_type + ? company_partner.aeat_identification + : (this.pos.company.vat || "").replace(/^ES/i, ""); + const date = this.validation_date || this.creation_date; + const formattedDate = moment(date).format("DD-MM-YYYY"); + const params = new URLSearchParams({ + nif: vatNumber, + numserie: this.l10n_es_unique_id, + fecha: formattedDate, + importe: this.get_total_with_tax(), + }); + return `${baseUrl}?${params.toString()}`; + } + + _get_verifactu_qr_code_data() { + const isEnabled = + this.pos.company.verifactu_enabled && + this.is_simplified_invoice && + (!this.fiscal_position || + (this.fiscal_position && this.fiscal_position.aeat_active)); + + if (isEnabled) { + const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter(); + const address = this._build_verifactu_qr_url(); + const hints = new Map(); + hints.set( + window.ZXing.EncodeHintType.ERROR_CORRECTION, + window.ZXing.QRCodeDecoderErrorCorrectionLevel.M + ); + hints.set(window.ZXing.EncodeHintType.MARGIN, 0); // Minimize quiet zone + const qr_code_svg = new XMLSerializer().serializeToString( + codeWriter.write(address, 150, 150, hints) + ); + return "data:image/svg+xml;base64," + window.btoa(qr_code_svg); + } + return false; + } + }; + Registries.Model.extend(Order, VerifactuOrder); +}); diff --git a/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml b/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml new file mode 100644 index 00000000000..85d85c74388 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml @@ -0,0 +1,23 @@ + + + + + +
+ QR Tributario: + + VERI*FACTU +
+
+
+
+
+
diff --git a/l10n_es_aeat_verifactu_pos/tests/__init__.py b/l10n_es_aeat_verifactu_pos/tests/__init__.py new file mode 100644 index 00000000000..a630e58ecc6 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/tests/__init__.py @@ -0,0 +1 @@ +from . import test_l10n_es_aeat_verifactu_pos diff --git a/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py b/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py new file mode 100644 index 00000000000..c127788a85e --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py @@ -0,0 +1,384 @@ +import uuid + +from psycopg2 import OperationalError + +from odoo import fields +from odoo.tests.common import tagged + +from odoo.addons.l10n_es_aeat_verifactu.tests.test_l10n_es_aeat_verifactu import ( + TestL10nEsAeatVerifactuBase, +) + + +@tagged("post_install", "-at_install") +class TestL10nEsAeatVerifactuPOS(TestL10nEsAeatVerifactuBase): + @classmethod + def copy_account(cls, account, default=None): + suffix_nb = 1 + while True: + new_code = "%s.%s" % (account.code, suffix_nb) + if account.search_count( + [("company_id", "=", account.company_id.id), ("code", "=", new_code)] + ): + suffix_nb += 1 + else: + return account.copy(default={**(default or {}), "code": new_code}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + + sequence = cls.env["ir.sequence"].create( + { + "name": "POS Simplified Invoice", + "prefix": "SIM/", + "padding": 4, + } + ) + sale_journal = cls.env["account.journal"].create( + { + "name": "PoS Sale EUR", + "type": "sale", + "code": "POSE", + "company_id": cls.company.id, + "sequence": 12, + "currency_id": cls.env.ref("base.EUR").id, + } + ) + invoice_sale_journal = sale_journal.copy( + { + "name": "Invoice Sale EUR", + "code": "ISE", + } + ) + cls.pos_config = cls.env["pos.config"].create( + { + "name": "Test POS", + "company_id": cls.company.id, + "l10n_es_simplified_invoice_limit": 3000, + "l10n_es_simplified_invoice_sequence_id": sequence.id, + "journal_id": sale_journal.id, + "invoice_journal_id": invoice_sale_journal.id, + "iface_l10n_es_simplified_invoice": True, + "default_partner_id": cls.env["res.partner"] + .create( + { + "name": "Test simplified default customer", + "aeat_simplified_invoice": True, + } + ) + .id, + } + ) + cls.company.account_default_pos_receivable_account_id = cls.env[ + "account.account" + ].create( + { + "code": "X1012.POS", + "name": "Debtors - (POS)", + "reconcile": True, + "account_type": "asset_receivable", + } + ) + cls.pos_receivable_account = ( + cls.company.account_default_pos_receivable_account_id + ) + cls.pos_receivable_cash = cls.copy_account( + cls.company.account_default_pos_receivable_account_id, + {"name": "POS Receivable Cash"}, + ) + cls.pos_receivable_bank = cls.copy_account( + cls.company.account_default_pos_receivable_account_id, + {"name": "POS Receivable Bank"}, + ) + cls.outstanding_bank = cls.copy_account( + cls.company.account_journal_payment_debit_account_id, + {"name": "Outstanding Bank"}, + ) + cls.default_journal_cash = cls.env["account.journal"].search( + [("company_id", "=", cls.company.id), ("type", "=", "cash")], limit=1 + ) + cls.default_journal_bank = cls.env["account.journal"].search( + [("company_id", "=", cls.company.id), ("type", "=", "bank")], limit=1 + ) + cls.cash_pm1 = cls.env["pos.payment.method"].create( + { + "name": "Cash", + "journal_id": cls.default_journal_cash.id, + "receivable_account_id": cls.pos_receivable_cash.id, + "company_id": cls.env.company.id, + } + ) + cls.bank_pm1 = cls.env["pos.payment.method"].create( + { + "name": "Bank", + "journal_id": cls.default_journal_bank.id, + "receivable_account_id": cls.pos_receivable_bank.id, + "outstanding_account_id": cls.outstanding_bank.id, + "company_id": cls.env.company.id, + } + ) + cls.pos_config.write( + {"payment_method_ids": [(6, 0, (cls.cash_pm1 + cls.bank_pm1).ids)]} + ) + cls.pos_config.open_ui() + cls.pos_session = cls.pos_config.current_session_id + + cls.tax_21 = cls.env.ref( + f"l10n_es.{cls.company.id}_account_tax_template_s_iva21b" + ) + cls.tax_10 = cls.env.ref( + f"l10n_es.{cls.company.id}_account_tax_template_s_iva10b" + ) + + def _create_ui_order_data(self, amount=100, simplified=True): + """Helper to create UI order data""" + uid = str(uuid.uuid4()) + return { + "data": { + "amount_paid": amount * 1.21, + "amount_total": amount * 1.21, + "amount_tax": amount * 0.21, + "amount_return": 0, + "creation_date": fields.Datetime.to_string(fields.Datetime.now()), + "fiscal_position_id": False, + "pricelist_id": self.pos_config.available_pricelist_ids[0].id, + "lines": [ + [ + 0, + 0, + { + "product_id": self.product.id, + "price_unit": amount, + "qty": 1, + "tax_ids": [[6, False, self.tax_21.ids]], + "price_subtotal": amount, + "price_subtotal_incl": amount * 1.21, + }, + ] + ], + "name": "Order 0001", + "pos_session_id": self.pos_session.id, + "sequence_number": 2, + "partner_id": self.partner.id, + "l10n_es_unique_id": simplified and "SIM/0001" or False, + "uid": uid, + "user_id": self.env.uid, + "statement_ids": [ + ( + 0, + 0, + { + "amount": amount * 1.21, + "name": fields.Datetime.now(), + "payment_method_id": self.cash_pm1.id, + }, + ) + ], + }, + "id": uid, + "to_invoice": not simplified, + } + + def test_simplified_invoice_verifactu_flow(self): + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + self.assertTrue( + order.is_l10n_es_simplified_invoice, + "Order should be marked as simplified invoice", + ) + self.assertEqual( + order.l10n_es_unique_id, + "SIM/0001", + "Order should have correct simplified invoice number", + ) + + self.assertTrue( + order.verifactu_enabled, + "Verifactu should be enabled for simplified invoices", + ) + self.assertEqual( + order._get_verifactu_document_type(), + "F2", + "Document type should be F2 for simplified invoices", + ) + self.assertEqual( + order._get_document_serial_number(), + "SIM/0001", + "Serial number should match simplified invoice number", + ) + + def test_verifactu_hash_string(self): + """Test the generation of Verifactu hash string for POS orders""" + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + hash_string = order._get_verifactu_hash_string() + components = dict(item.split("=") for item in hash_string.split("&")) + + self.assertEqual( + components["IDEmisorFactura"], + self.company.partner_id._parse_aeat_vat_info()[2], + "Incorrect issuer ID", + ) + self.assertEqual( + components["NumSerieFactura"], + "SIM/0001", + "Incorrect serial number", + ) + self.assertEqual( + components["TipoFactura"], + "F2", + "Incorrect document type for POS order", + ) + self.assertEqual( + float(components["CuotaTotal"]), + 21.0, + "Incorrect tax amount", + ) + self.assertEqual( + float(components["ImporteTotal"]), + 121.0, + "Incorrect total amount", + ) + + def test_verifactu_invoice_dict_out(self): + """Test the generation of outgoing invoice dictionary for POS orders""" + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + result = order._get_verifactu_invoice_dict_out() + self.assertIn("RegistroAlta", result) + alta = result["RegistroAlta"] + + self.assertEqual( + alta["IDFactura"]["IDEmisorFactura"], + self.company.partner_id._parse_aeat_vat_info()[2], + ) + self.assertEqual(alta["IDFactura"]["NumSerieFactura"], "SIM/0001") + self.assertEqual(alta["TipoFactura"], "F2") + self.assertEqual(float(alta["CuotaTotal"]), 21.0) + self.assertEqual(float(alta["ImporteTotal"]), 121.0) + + def test_verifactu_chaining_first_order(self): + """Test chaining when there's no previous POS order""" + self.pos_config.verifactu_last_invoice_id = False + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + result = order._get_chaining_invoice_dict() + self.assertEqual( + result, + {"PrimerRegistro": "S"}, + "Should indicate first record when no previous order exists", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + order.id, + "Config's last order should be updated for first record", + ) + + def test_verifactu_chaining_with_previous(self): + """Test chaining when there's a previous POS order""" + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + first_order.verifactu_hash = "TEST_HASH" + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) # New unique ID + second_order_data["id"] = second_order_data["data"]["uid"] + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + + result = second_order._get_chaining_invoice_dict() + self.assertIn("RegistroAnterior", result) + self.assertEqual( + result["RegistroAnterior"]["NumSerieFactura"], + "SIM/0001", + "Should contain previous order reference", + ) + self.assertEqual( + result["RegistroAnterior"]["Huella"], + "TEST_HASH", + "Should contain previous order hash", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + second_order.id, + "Should update config's last order reference", + ) + + def test_verifactu_chaining_operational_error(self): + """Test handling of OperationalError during chaining""" + + def mock_execute(*args, **kwargs): + raise OperationalError("Test lock error") + + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) # New unique ID + second_order_data["id"] = second_order_data["data"]["uid"] + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + + old_execute = self.cr.execute + with self.assertRaises(OperationalError): + with self.cr.savepoint(): + self.cr.execute = mock_execute + second_order._get_chaining_invoice_dict() + self.cr.execute = old_execute + + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + first_order.id, + "Should not update config's last order reference on error", + ) + + def test_verifactu_chaining_invoiced_pos_order(self): + """Test that invoiced POS orders are added to POS config chain""" + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + first_order.verifactu_hash = "FIRST_HASH" + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200, simplified=False) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) + second_order_data["id"] = second_order_data["data"]["uid"] + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + + second_order.company_id.verifactu_last_invoice_id = False + + second_order.action_pos_order_invoice() + invoice = second_order.account_move + + result = invoice._get_chaining_invoice_dict() + self.assertIn( + "RegistroAnterior", result, "Invoice should have previous record info" + ) + self.assertEqual( + result["RegistroAnterior"]["Huella"], + "FIRST_HASH", + "Invoice should link to previous POS order hash", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + second_order.id, + "Config's last order should be the POS order, not the invoice", + ) + self.assertFalse(invoice.company_id.verifactu_last_invoice_id.exists()) diff --git a/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml b/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml new file mode 100644 index 00000000000..04848013ad7 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml @@ -0,0 +1,86 @@ + + + + pos.order.verifactu.form + pos.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos b/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos new file mode 120000 index 00000000000..ed677d45f65 --- /dev/null +++ b/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos @@ -0,0 +1 @@ +../../../../l10n_es_aeat_verifactu_pos \ No newline at end of file diff --git a/setup/l10n_es_aeat_verifactu_pos/setup.py b/setup/l10n_es_aeat_verifactu_pos/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/l10n_es_aeat_verifactu_pos/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)