From 94568bbfb6c0e471a0b5633116541c48828449a6 Mon Sep 17 00:00:00 2001 From: Maxime Chambreuil Date: Thu, 18 Jul 2024 14:44:38 -0600 Subject: [PATCH] [ADD] l10n_mx_cfdi (#41) --- l10n_mx_cfdi/README.rst | 106 +++ l10n_mx_cfdi/__init__.py | 2 + l10n_mx_cfdi/__manifest__.py | 41 + l10n_mx_cfdi/data/cfdi_publico_en_general.xml | 17 + l10n_mx_cfdi/data/paper_format.xml | 8 + l10n_mx_cfdi/models/__init__.py | 15 + l10n_mx_cfdi/models/account_move.py | 700 ++++++++++++++++++ l10n_mx_cfdi/models/account_move_line.py | 177 +++++ l10n_mx_cfdi/models/account_payment.py | 207 ++++++ l10n_mx_cfdi/models/account_tax.py | 20 + l10n_mx_cfdi/models/cfdi_document.py | 538 ++++++++++++++ l10n_mx_cfdi/models/cfdi_issuer.py | 134 ++++ l10n_mx_cfdi/models/cfdi_series.py | 16 + l10n_mx_cfdi/models/cfdi_service.py | 190 +++++ l10n_mx_cfdi/models/cfdi_service_topup.py | 48 ++ l10n_mx_cfdi/models/product_template.py | 15 + l10n_mx_cfdi/models/res_company.py | 21 + l10n_mx_cfdi/models/res_config_settings.py | 12 + l10n_mx_cfdi/models/res_partner.py | 19 + l10n_mx_cfdi/readme/CONTRIBUTORS.rst | 2 + l10n_mx_cfdi/readme/DESCRIPTION.rst | 25 + l10n_mx_cfdi/readme/USAGE.rst | 9 + l10n_mx_cfdi/reports/report_cfdi_blocks.xml | 270 +++++++ .../reports/report_external_layouts.xml | 423 +++++++++++ l10n_mx_cfdi/reports/report_invoice.xml | 137 ++++ l10n_mx_cfdi/reports/report_payment.xml | 134 ++++ l10n_mx_cfdi/security/ir.model.access.csv | 14 + .../security/l10n_mx_cfdi_security.xml | 55 ++ l10n_mx_cfdi/static/description/icon.png | Bin 0 -> 3050 bytes l10n_mx_cfdi/static/description/icon.svg | 51 ++ l10n_mx_cfdi/static/description/index.html | 460 ++++++++++++ l10n_mx_cfdi/tests/__init__.py | 13 + l10n_mx_cfdi/tests/test_account_move.py | 10 + l10n_mx_cfdi/tests/test_account_move_line.py | 104 +++ l10n_mx_cfdi/tests/test_account_payment.py | 142 ++++ l10n_mx_cfdi/tests/test_account_tax.py | 44 ++ l10n_mx_cfdi/tests/test_cfdi_document.py | 134 ++++ l10n_mx_cfdi/tests/test_cfdi_issuer.py | 53 ++ l10n_mx_cfdi/tests/test_cfdi_series.py | 21 + l10n_mx_cfdi/tests/test_cfdi_service.py | 62 ++ l10n_mx_cfdi/tests/test_cfdi_service_topup.py | 29 + l10n_mx_cfdi/tests/test_res_company.py | 41 + l10n_mx_cfdi/views/account_move.xml | 36 + l10n_mx_cfdi/views/account_payment.xml | 47 ++ .../views/account_payment_register.xml | 12 + l10n_mx_cfdi/views/cfdi_document.xml | 163 ++++ l10n_mx_cfdi/views/cfdi_documents_issued.xml | 19 + l10n_mx_cfdi/views/cfdi_issuer.xml | 113 +++ l10n_mx_cfdi/views/cfdi_menu.xml | 10 + l10n_mx_cfdi/views/cfdi_series.xml | 50 ++ l10n_mx_cfdi/views/cfdi_service.xml | 57 ++ l10n_mx_cfdi/views/product_template.xml | 17 + l10n_mx_cfdi/views/res_config_settings.xml | 30 + l10n_mx_cfdi/views/res_partner.xml | 18 + l10n_mx_cfdi/wizards/__init__.py | 4 + .../wizards/account_invoice_send_views.xml | 15 + .../wizards/account_payment_register.py | 39 + .../wizards/create_cfdi_publico_en_general.py | 166 +++++ .../create_cfdi_publico_en_general.xml | 64 ++ l10n_mx_cfdi/wizards/document_cancel.py | 75 ++ l10n_mx_cfdi/wizards/document_cancel_form.xml | 44 ++ l10n_mx_cfdi/wizards/download_cfdi_files.py | 79 ++ .../wizards/download_cfdi_files_wizard.xml | 42 ++ 63 files changed, 5619 insertions(+) create mode 100644 l10n_mx_cfdi/README.rst create mode 100644 l10n_mx_cfdi/__init__.py create mode 100644 l10n_mx_cfdi/__manifest__.py create mode 100644 l10n_mx_cfdi/data/cfdi_publico_en_general.xml create mode 100644 l10n_mx_cfdi/data/paper_format.xml create mode 100644 l10n_mx_cfdi/models/__init__.py create mode 100644 l10n_mx_cfdi/models/account_move.py create mode 100644 l10n_mx_cfdi/models/account_move_line.py create mode 100644 l10n_mx_cfdi/models/account_payment.py create mode 100644 l10n_mx_cfdi/models/account_tax.py create mode 100644 l10n_mx_cfdi/models/cfdi_document.py create mode 100644 l10n_mx_cfdi/models/cfdi_issuer.py create mode 100644 l10n_mx_cfdi/models/cfdi_series.py create mode 100644 l10n_mx_cfdi/models/cfdi_service.py create mode 100644 l10n_mx_cfdi/models/cfdi_service_topup.py create mode 100644 l10n_mx_cfdi/models/product_template.py create mode 100644 l10n_mx_cfdi/models/res_company.py create mode 100644 l10n_mx_cfdi/models/res_config_settings.py create mode 100644 l10n_mx_cfdi/models/res_partner.py create mode 100644 l10n_mx_cfdi/readme/CONTRIBUTORS.rst create mode 100644 l10n_mx_cfdi/readme/DESCRIPTION.rst create mode 100644 l10n_mx_cfdi/readme/USAGE.rst create mode 100644 l10n_mx_cfdi/reports/report_cfdi_blocks.xml create mode 100644 l10n_mx_cfdi/reports/report_external_layouts.xml create mode 100644 l10n_mx_cfdi/reports/report_invoice.xml create mode 100644 l10n_mx_cfdi/reports/report_payment.xml create mode 100644 l10n_mx_cfdi/security/ir.model.access.csv create mode 100644 l10n_mx_cfdi/security/l10n_mx_cfdi_security.xml create mode 100644 l10n_mx_cfdi/static/description/icon.png create mode 100644 l10n_mx_cfdi/static/description/icon.svg create mode 100644 l10n_mx_cfdi/static/description/index.html create mode 100644 l10n_mx_cfdi/tests/__init__.py create mode 100644 l10n_mx_cfdi/tests/test_account_move.py create mode 100644 l10n_mx_cfdi/tests/test_account_move_line.py create mode 100644 l10n_mx_cfdi/tests/test_account_payment.py create mode 100644 l10n_mx_cfdi/tests/test_account_tax.py create mode 100644 l10n_mx_cfdi/tests/test_cfdi_document.py create mode 100644 l10n_mx_cfdi/tests/test_cfdi_issuer.py create mode 100644 l10n_mx_cfdi/tests/test_cfdi_series.py create mode 100644 l10n_mx_cfdi/tests/test_cfdi_service.py create mode 100644 l10n_mx_cfdi/tests/test_cfdi_service_topup.py create mode 100644 l10n_mx_cfdi/tests/test_res_company.py create mode 100644 l10n_mx_cfdi/views/account_move.xml create mode 100644 l10n_mx_cfdi/views/account_payment.xml create mode 100644 l10n_mx_cfdi/views/account_payment_register.xml create mode 100644 l10n_mx_cfdi/views/cfdi_document.xml create mode 100644 l10n_mx_cfdi/views/cfdi_documents_issued.xml create mode 100644 l10n_mx_cfdi/views/cfdi_issuer.xml create mode 100644 l10n_mx_cfdi/views/cfdi_menu.xml create mode 100644 l10n_mx_cfdi/views/cfdi_series.xml create mode 100644 l10n_mx_cfdi/views/cfdi_service.xml create mode 100644 l10n_mx_cfdi/views/product_template.xml create mode 100644 l10n_mx_cfdi/views/res_config_settings.xml create mode 100644 l10n_mx_cfdi/views/res_partner.xml create mode 100644 l10n_mx_cfdi/wizards/__init__.py create mode 100644 l10n_mx_cfdi/wizards/account_invoice_send_views.xml create mode 100644 l10n_mx_cfdi/wizards/account_payment_register.py create mode 100644 l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.py create mode 100644 l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.xml create mode 100644 l10n_mx_cfdi/wizards/document_cancel.py create mode 100644 l10n_mx_cfdi/wizards/document_cancel_form.xml create mode 100644 l10n_mx_cfdi/wizards/download_cfdi_files.py create mode 100644 l10n_mx_cfdi/wizards/download_cfdi_files_wizard.xml diff --git a/l10n_mx_cfdi/README.rst b/l10n_mx_cfdi/README.rst new file mode 100644 index 0000000..586dee2 --- /dev/null +++ b/l10n_mx_cfdi/README.rst @@ -0,0 +1,106 @@ +============================= +Mexico - Electronic Invoicing +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:41a2fefd24df10a8a68742d65502368dc832b77b6e3fd7d74ab4f81348cc6464 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--mexico-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-mexico/tree/15.0/l10n_mx_cfdi + :alt: OCA/l10n-mexico +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-mexico-15-0/l10n-mexico-15-0-l10n_mx_cfdi + :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-mexico&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +============ +l10n_mx_cfdi +============ + +This module provides electronic invoicing for Mexico (CFDI 4.0) using Facturama as the only PAC (for now) + +Features +-------- +- Generation of electronic invoices compliant with the CFDI 4.0 standard. +- Integration with Facturama (for now) as the PAC for the issuance and stamping of invoices. +- Customization of fiscal documents according to user needs. +- Centralized management of electronic invoices within Odoo. +- Tracking and recording of issued and received fiscal documents. + +System Requirements +------------------- +- Odoo 15.0 +- Active account in Facturama +- Pre-configuration of fiscal and company data in Odoo. + +Installation +------------ +1. Log in to Odoo as an administrator and navigate to the applications section. +2. Search for "l10n_mx_cfdi" and click install. +3. Configure module settings by entering access credentials for Facturama and other required details. + +**Table of contents** + +.. contents:: + :local: + + +Usage +----- + + +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 +~~~~~~~ + +* Alexis López Zubieta (Auge TEC) + +Contributors +~~~~~~~~~~~~ + +* Alexis López Zubieta +* Maxime Chambreuil + +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-mexico `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_mx_cfdi/__init__.py b/l10n_mx_cfdi/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/l10n_mx_cfdi/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/l10n_mx_cfdi/__manifest__.py b/l10n_mx_cfdi/__manifest__.py new file mode 100644 index 0000000..e1d8cfd --- /dev/null +++ b/l10n_mx_cfdi/__manifest__.py @@ -0,0 +1,41 @@ +{ + "name": "Mexico - Electronic Invoicing", + "icon": "/l10n_mx/static/description/icon.png", + "summary": "Allow generating CFDI (Comprobante Fiscal Digital por Internet) for Mexico.", + "author": "Alexis López Zubieta (Auge TEC), " + "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-mexico", + "license": "LGPL-3", + "category": "Accounting", + "version": "15.0.1.0.0", + "depends": ["account", "l10n_mx", "l10n_mx_catalogs"], + "external_dependencies": { + "python": ["facturama"], + }, + "data": [ + "security/ir.model.access.csv", + "security/l10n_mx_cfdi_security.xml", + "data/cfdi_publico_en_general.xml", + "data/paper_format.xml", + "views/account_move.xml", + "views/res_partner.xml", + "views/product_template.xml", + "views/account_payment_register.xml", + "views/account_payment.xml", + "views/res_config_settings.xml", + "views/cfdi_menu.xml", + "views/cfdi_series.xml", + "views/cfdi_issuer.xml", + "views/cfdi_service.xml", + "views/cfdi_document.xml", + "views/cfdi_documents_issued.xml", + "wizards/document_cancel_form.xml", + "wizards/create_cfdi_publico_en_general.xml", + "wizards/account_invoice_send_views.xml", + "wizards/download_cfdi_files_wizard.xml", + "reports/report_cfdi_blocks.xml", + "reports/report_external_layouts.xml", + "reports/report_invoice.xml", + "reports/report_payment.xml", + ], +} diff --git a/l10n_mx_cfdi/data/cfdi_publico_en_general.xml b/l10n_mx_cfdi/data/cfdi_publico_en_general.xml new file mode 100644 index 0000000..19d2931 --- /dev/null +++ b/l10n_mx_cfdi/data/cfdi_publico_en_general.xml @@ -0,0 +1,17 @@ + + + + PUBLICO EN GENERAL + + XAXX010101000 + + + + + + + + diff --git a/l10n_mx_cfdi/data/paper_format.xml b/l10n_mx_cfdi/data/paper_format.xml new file mode 100644 index 0000000..ce0cf0e --- /dev/null +++ b/l10n_mx_cfdi/data/paper_format.xml @@ -0,0 +1,8 @@ + + + + 60 + 55 + + + diff --git a/l10n_mx_cfdi/models/__init__.py b/l10n_mx_cfdi/models/__init__.py new file mode 100644 index 0000000..970a9e2 --- /dev/null +++ b/l10n_mx_cfdi/models/__init__.py @@ -0,0 +1,15 @@ +from . import ( + cfdi_series, + cfdi_service, + cfdi_service_topup, + cfdi_issuer, + cfdi_document, + res_company, + res_config_settings, + res_partner, + product_template, + account_move, + account_move_line, + account_tax, + account_payment, +) diff --git a/l10n_mx_cfdi/models/account_move.py b/l10n_mx_cfdi/models/account_move.py new file mode 100644 index 0000000..b7ee23c --- /dev/null +++ b/l10n_mx_cfdi/models/account_move.py @@ -0,0 +1,700 @@ +import base64 +from datetime import datetime, timedelta + +from lxml import etree + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import json_float_round + + +class AccountMove(models.Model): + """ + Integration with the Mexican CFDI 4.0 system for electronic invoices + """ + + _inherit = "account.move" + + cfdi_document_id = fields.Many2one( + "l10n_mx_cfdi.document", + string="CFDI", + readonly=True, + copy=False, + compute="_compute_cfdi_document_id", + store=True, + ) + + cfdi_document_name = fields.Char( + string="Folio CFDI", readonly=True, related="cfdi_document_id.name", store=True + ) + cfdi_document_state = fields.Selection( + string="CFDI Status", readonly=True, related="cfdi_document_id.state" + ) + + related_cert_ids = fields.Many2many( + "l10n_mx_cfdi.document", string="Documentos", readonly=True, copy=False + ) + + # Invoice CFDI required fields + cfdi_required = fields.Boolean(string="Requiere CFDI", default=False) + + issuer_id = fields.Many2one( + "l10n_mx_cfdi.issuer", string="Emisor", domain=[("registered", "=", True)] + ) + receiver_id = fields.Many2one("res.partner", string="Receptor", readonly=True) + + cfdi_use_id = fields.Many2one("l10n_mx_catalogs.c_uso_cfdi", string="Uso de CFDI") + payment_method_id = fields.Many2one( + "l10n_mx_catalogs.c_metodo_pago", string="Método de pago" + ) + payment_form_id = fields.Many2one( + "l10n_mx_catalogs.c_forma_pago", string="Forma de pago" + ) + + cfdi_posted = fields.Boolean( + string="Requiere CFDI", compute="_compute_cfdi_posted", store=True + ) + cfdi_data_in_attachments = fields.Boolean( + string="CFDI data in attachments", compute="_compute_cfdi_data_in_attachments" + ) + + l10n_mx_cfdi_auto = fields.Boolean( + string="CFDI Automatico", related="company_id.l10n_mx_cfdi_auto", readonly=True + ) + l10n_mx_cfdi_enabled = fields.Boolean( + string="CFDI Habilitado", + related="company_id.l10n_mx_cfdi_enabled", + readonly=True, + ) + + @api.depends("cfdi_document_id") + def _compute_cfdi_data_in_attachments(self): + # remove 'bin_size' from the context to allow data to be read + self = self.with_context(bin_size=False) + for move in self: + move.cfdi_data_in_attachments = False + + # get xml attachments + xml_attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", self.id), + ("mimetype", "=", "application/xml"), + ] + ) + + for attachment in xml_attachments: + xml = base64.b64decode(attachment.datas) + if b"cfdi:Comprobante" in xml: + move.cfdi_data_in_attachments = True + + @api.model + def default_get(self, field_names): + defaults_dict = super().default_get(field_names) + defaults_dict["receiver_id"] = defaults_dict.get("partner_id") + if self.partner_id and not self.receiver_id: + self.receiver_id = self.partner_id + self.cfdi_use_id = self.partner_id.cfdi_use_id + self.payment_method_id = self.partner_id.payment_method_id + self.payment_form_id = self.partner_id.payment_form_id + + # set issuer if there is only one choice + issuers = self.env["l10n_mx_cfdi.issuer"].search([("registered", "=", True)]) + if len(issuers) == 1: + defaults_dict["issuer_id"] = issuers[0].id + + return defaults_dict + + @api.onchange("partner_id") + def _update_receiver(self): + """ + Update the receiver_id field + """ + for move in self: + move.receiver_id = move.partner_id + move._update_cfdi_data() + + @api.onchange("receiver_id") + def _update_cfdi_data(self): + """ + Update the CFDI data when the receiver_id changes + """ + for move in self: + if move.receiver_id: + move.cfdi_use_id = move.receiver_id.cfdi_use_id + move.payment_method_id = move.receiver_id.payment_method_id + move.payment_form_id = move.receiver_id.payment_form_id + + @api.depends("related_cert_ids") + def _compute_cfdi_document_id(self): + for move in self: + # remove current reference + move.cfdi_document_id = False + + # get the last CFDI + if move.move_type in ("in_invoice", "out_invoice"): + move.cfdi_document_id = move.related_cert_ids.filtered( + lambda x: x.type == "I" and x.state == "published" + ) + + if move.move_type == "out_refund": + move.cfdi_document_id = move.related_cert_ids.filtered( + lambda x: x.type == "E" and x.state == "published" + ) + + if move.move_type == "in_payment": + move.cfdi_document_id = move.related_cert_ids.filtered( + lambda x: x.type == "P" and x.state == "published" + ) + + @api.depends("related_cert_ids") + def _compute_cfdi_posted(self): + for move in self: + if move.cfdi_document_id and move.cfdi_document_id.state == "published": + move.cfdi_posted = True + else: + move.cfdi_posted = False + + def action_post(self): + """ + Override the action_post method to create the CFDI + """ + + res = super().action_post() + + if self.l10n_mx_cfdi_auto: + # Create the CFDIs if required + for move in self: + if ( + move.move_type == "out_invoice" + and move.cfdi_required + and move.cfdi_document_id.state != "published" + ): + move.create_invoice_cfdi() + + return res + + def create_invoice_cfdi(self): + """ + Create the CFDI + """ + self.ensure_one() + + self._validate_invoice_cfdi_required_fields() + + cert = self.env["l10n_mx_cfdi.document"].create( + { + "type": "I", + "issuer_id": self.issuer_id.id, + "receiver_id": self.receiver_id.id, + "related_invoice_id": self.id, + } + ) + + try: + cfdi_data = self._gather_invoice_cfdi_data() + cert.publish(cfdi_data) + + self.update( + { + "related_cert_ids": [(4, cert.id)], + } + ) + + except Exception as e: + cert.unlink() + raise e + + def _validate_invoice_cfdi_required_fields(self): + """ + Validate the CFDI required fields + """ + self.ensure_one() + err_msg = "" + + # validate issuer + if not self.issuer_id: + err_msg += "- No se ha definido el emisor\n" + + # validate partner data + if not self.receiver_id.vat: + err_msg += "- No se ha definido el RFC del receptor\n" + + if not self.receiver_id.tax_regime: + err_msg += "- No se ha definido el régimen fiscal del receptor\n" + + if not self.receiver_id.zip and self.receiver_id.vat != "XAXX010101000": + err_msg += "- No se ha definido el código postal del receptor\n" + + if not self.cfdi_use_id: + err_msg += "- No se ha definido el uso del CFDI\n" + + if not self.payment_method_id: + err_msg += "- No se ha definido el método de pago\n" + + if not self.payment_form_id: + err_msg += "- No se ha definido la forma de pago\n" + + err_msg += self.validate_invoice_items_for_cfdi_generation() + + if err_msg: + raise ValidationError(_("Cannot generate the CFDI:\n") + err_msg) + + def _gather_invoice_cfdi_data(self): + cfdi_data = { + "Currency": self.company_currency_id.name, + "ExpeditionPlace": self.issuer_id.zip, + "Date": self._format_cfdi_date_str(self.invoice_date), + "CfdiType": "I", + "PaymentForm": self.payment_form_id.code, + "PaymentMethod": self.payment_method_id.code, + "Receiver": { + "Name": self.receiver_id.name, + "Rfc": self.receiver_id.vat, + "CfdiUse": self.cfdi_use_id.code, + "FiscalRegime": self.receiver_id.tax_regime.code, + "TaxZipCode": self.receiver_id.zip, + }, + "Items": self.gather_invoice_cfdi_items_data(), + } + + self._add_global_information_to_cfdi_if_required(cfdi_data) + + return cfdi_data + + def _format_cfdi_date_str(self, document_date): + """ + Format the date to be used in the CFDI + + This method will add the time to the document_date to make it + compatible with the CFDI format. Then will format the date to + ISO 8601 format. + + """ + fixed_tz_recordset = self.with_context(**{"tz": self.env.user.tz}) + now_utc = fields.datetime.now() + now_utc_tz = fields.Datetime.context_timestamp(fixed_tz_recordset, now_utc) + + # add 2h if there is a difference larger than 24h between + # this is a workaround to avoid issues with the PAC when + # signing a CFDI with a date in the past + if (now_utc_tz.date() - document_date).days > 1: + # add 2h to now_utc_tz + now_utc_tz = now_utc_tz + timedelta(hours=2) + + # add time info to invoice_date + document_date = datetime.combine(document_date, now_utc_tz.time()) + + # invoice_date to ISO 8601 format + document_date_str = document_date.strftime("%Y-%m-%dT%H:%M:%S") + return document_date_str + + def gather_invoice_cfdi_items_data(self): + """ + Gather the data for the CFDI items + """ + self.ensure_one() + + cfdi_items_data = [] + for line in self.line_ids: + if line.exclude_from_invoice_tab or not line.product_id: + continue + + cfdi_item_data = line._gater_cfdi_item_data() + cfdi_items_data.append(cfdi_item_data) + + return cfdi_items_data + + def gater_invoice_cfdi_item_data(self, line): + """Gather the data for a CFDI item. + :param line: The invoice line + :return: The CFDI item data + """ + + cfdi_item_data = line._gater_cfdi_item_data() + + return cfdi_item_data + + def validate_invoice_items_for_cfdi_generation(self): + err_msg = "" + # validate invoice items + for line in self.line_ids: + if line.exclude_from_invoice_tab or not line.product_id: + continue + + if not line.product_id.l10n_mx_cfdi_product_code_id: + err_msg += ( + "- No se ha definido el código de producto para el producto %s\n" + % line.product_id.name + ) + + if not line.product_id.l10n_mx_cfdi_product_measurement_unit_id: + err_msg += ( + "- No se ha definido la unidad de medida para el producto %s\n" + % line.product_id.name + ) + + return err_msg + + @api.model + def _gather_invoice_cfdi_item_taxes_data(self, line, discount): + """Gather the taxes data for a CFDI item.""" + + price_unit_wo_discount = line.price_unit - discount + + taxes = [] + for tax_id in line.tax_ids: + computed_tax = tax_id.compute_all( + price_unit_wo_discount, + quantity=line.quantity, + currency=line.currency_id, + ) + tax_rate = ( + tax_id.amount / 100 + if tax_id.amount_type == "percent" + else tax_id.amount + ) + tax_total = ( + computed_tax["taxes"][0]["amount"] if computed_tax["taxes"] else 0 + ) + taxes.append( + { + "Name": tax_id.extract_l10n_mx_tax_code(), + "Rate": tax_rate, + "IsRetention": tax_id.extract_is_retention(), + "Base": computed_tax["total_excluded"], + "Total": tax_total, + } + ) + return taxes + + def prepare_invoice_cfdi_total_taxes(self): + self.ensure_one() + + total_taxes = {} + for line in self.line_ids: + if line.tax_line_id: + tax_id = line.tax_line_id + tax_code = tax_id.extract_l10n_mx_tax_code() + if not tax_code: + raise UserError( + _("The tax code for tax %s is not defined.") + % line.tax_ids[0].name + ) + + tax_rate = ( + tax_id.amount / 100 + if tax_id.amount_type == "percent" + else tax_id.amount + ) + + if tax_code in total_taxes: + total_taxes[tax_code]["Base"] += line.tax_base_amount + total_taxes[tax_code]["Total"] += line.price_total + else: + total_taxes[tax_code] = { + "Name": tax_code, + "Rate": tax_rate, + "IsRetention": tax_id.extract_is_retention(), + "Base": line.tax_base_amount, + "Total": line.price_total, + } + + # prepare float values to be serialized as JSON + for _k, v in total_taxes.items(): + v["Base"] = json_float_round(v["Base"], 2) + v["Total"] = json_float_round(v["Total"], 2) + + return list(total_taxes.values()) + + def button_draft(self): + for rec in self: + if rec.l10n_mx_cfdi_auto: + published_related_cfdi = rec.related_cert_ids.filtered_domain( + [("state", "=", "published")] + ) + if len(published_related_cfdi) > 0 and rec.move_type != "in_invoice": + # show CFDI cancel dialog + return ( + rec.env.ref("l10n_mx_cfdi.document_cancel_action") + .sudo() + .read()[0] + ) + + return super().button_draft() + + def create_refund_cfdi(self): + """ + Create CFDI of type 'E' (Egreso). + """ + for refund in self: + items_data = self.gather_invoice_cfdi_items_data() + + receivables = refund.line_ids.filtered( + lambda L: L.account_id.user_type_id.type == "receivable" + ) + partial_reconcile = self.env["account.partial.reconcile"].search( + [("debit_move_id", "in", receivables.ids)] + ) + partial_reconcile |= ( + receivables.matched_debit_ids + receivables.matched_credit_ids + ) + + move_lines = ( + partial_reconcile.credit_move_id + partial_reconcile.debit_move_id + ) + + related_cfdis = move_lines.move_id.related_cert_ids.filtered_domain( + [ + ("state", "=", "published"), + ("type", "=", "I"), + ] + ) + + cfdi_data = { + "NameId": "2", + "ExpeditionPlace": refund.issuer_id.zip, + "Date": self._format_cfdi_date_str(self.invoice_date), + "PaymentForm": refund.payment_form_id.code, + "PaymentMethod": refund.payment_method_id.code, + "Receiver": { + "Name": refund.partner_id.name, + "Rfc": refund.partner_id.vat, + "CfdiUse": refund.cfdi_use_id.code, + "FiscalRegime": refund.partner_id.tax_regime.code, + "TaxZipCode": refund.partner_id.zip, + }, + "Items": items_data, + "Relations": { + "Type": "01", + "Cfdis": [ + {"Uuid": related_cfdi.uuid} for related_cfdi in related_cfdis + ], + }, + } + + refund_cfdi = self.env["l10n_mx_cfdi.document"].create( + { + "type": "E", + "issuer_id": refund.issuer_id.id, + "receiver_id": refund.receiver_id.id, + "related_invoice_id": refund.id, + } + ) + + self._add_global_information_to_cfdi_if_required(cfdi_data) + + # register relations + refund_cfdi.update( + { + "related_document_ids": [ + ( + 0, + 0, + { + "source_id": refund_cfdi.id, + "target_id": related_cfdi.id, + "relation_type_id": self.env.ref( + "l10n_mx_catalogs.c_tipo_relacion_1" + ).id, + }, + ) + for related_cfdi in related_cfdis + ] + } + ) + + try: + refund_cfdi.publish(cfdi_data) + + refund.update( + { + "related_cert_ids": [(4, refund_cfdi.id)], + } + ) + + for cfdi in related_cfdis: + if cfdi.related_invoice_id: + cfdi.related_invoice_id.related_cert_ids |= refund_cfdi + + except Exception as e: + refund_cfdi.unlink() + raise e + + def _add_global_information_to_cfdi_if_required(self, cfdi_data): + if self.receiver_id.vat == "XAXX010101000": + currentDateTime = datetime.now() + + cfdi_data["GlobalInformation"] = { + "Periodicity": "01", # Daily periodicity + "Months": str(currentDateTime.month).rjust(2, "0"), + "Year": currentDateTime.year, + } + + cfdi_data["Receiver"]["TaxZipCode"] = self.issuer_id.zip + cfdi_data["Receiver"]["FiscalRegime"] = "616" + + @api.returns("self", lambda value: value.id) + def copy(self, default=None): + # avoid copying the related cfdis + default = (default or {}).update( + { + "related_cert_ids": [(6, 0, [])], + } + ) + + return super().copy(default) + + def _get_name_invoice_report(self): + self.ensure_one() + if self.company_id.account_fiscal_country_id.code == "MX": + return "l10n_mx_cfdi.report_invoice_document" + + return super()._get_name_invoice_report() + + def action_load_from_attachment(self): + self.ensure_one() + + # find xml attachment + xml_attachment = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", self.id), + ("mimetype", "=", "application/xml"), + ], + limit=1, + ) + + if not xml_attachment: + raise UserError(_("No XML attachment found for this invoice.")) + + # decode attachment + xml = base64.b64decode(xml_attachment.datas) + + cfdi = self._parse_cfdi_xml(xml) + cfdi.xml_file = xml + + self.related_cert_ids |= cfdi + + def _parse_cfdi_xml(self, xml): + # parse CFDI XML + root = etree.fromstring(xml) + namespaces = root.nsmap + + # add tfd namespace + namespaces["tfd"] = "http://www.sat.gob.mx/TimbreFiscalDigital" + + cfdi_data = { + "type": root.attrib["TipoDeComprobante"], + "serie": root.attrib.get("Serie", ""), + "folio": root.attrib.get("Folio", ""), + "state": "published", + "related_invoice_id": self.id, + } + + # get uuid + timbre_fiscal = root.find( + "./cfdi:Complemento/tfd:TimbreFiscalDigital", namespaces + ) + cfdi_data["uuid"] = timbre_fiscal.attrib["UUID"] + + issuer_id = self._resolve_issuer_from_xml(namespaces, root) + cfdi_data["issuer_id"] = issuer_id.id + self.issuer_id = issuer_id + + receiver_id, cfdi_use = self._resolve_receiver_data_from_xml(namespaces, root) + cfdi_data["receiver_id"] = receiver_id.id + cfdi_use_model = self.env["l10n_mx_catalogs.c_uso_cfdi"] + cfdi_use = cfdi_use_model.search([("code", "=", cfdi_use)], limit=1) + + self.receiver_id = receiver_id + self.cfdi_use_id = cfdi_use + + # create or update cfdi document + cfdi_document_model = self.env["l10n_mx_cfdi.document"] + document = cfdi_document_model.search( + [("uuid", "=", cfdi_data["uuid"])], limit=1 + ) + if document: + document.write(cfdi_data) + else: + document = cfdi_document_model.create(cfdi_data) + + self.cfdi_document_id = document + self.cfdi_required = True + + # resolve payment form + payment_form_model = self.env["l10n_mx_catalogs.c_forma_pago"] + payment_form_code = root.attrib["FormaPago"] + self.payment_form_id = payment_form_model.search( + [("code", "=", payment_form_code)], limit=1 + ) + + # resolve payment method + payment_method_model = self.env["l10n_mx_catalogs.c_metodo_pago"] + payment_method_code = root.attrib["MetodoPago"] + self.payment_method_id = payment_method_model.search( + [("code", "=", payment_method_code)], limit=1 + ) + + return document + + def _resolve_receiver_data_from_xml(self, namespaces, root): + # get receiver + receiver = root.find("cfdi:Receptor", namespaces) + receiver_id = self.env["res.partner"].search( + [("vat", "=", receiver.attrib["Rfc"])], limit=1 + ) + if not receiver_id: + raise UserError( + _("Cannot find the receptor of the certificate. RFC: %s") + % receiver.attrib["Rfc"] + ) + + cfdi_use = receiver.attrib["UsoCFDI"] + return receiver_id, cfdi_use + + def _resolve_issuer_from_xml(self, namespaces, root): + # get issuer + issuer = root.find("cfdi:Emisor", namespaces) + issuer_id = self.env["l10n_mx_cfdi.issuer"].search( + [("vat", "=", issuer.attrib["Rfc"])] + ) + if not issuer_id: + # find partner + partner_id = self.env["res.partner"].search( + [("vat", "=", issuer.attrib["Rfc"])] + ) + if not partner_id: + raise UserError( + _("Cannot find the partner who emitted the certificate. " "RFC: %s") + % issuer.attrib["Rfc"] + ) + + # create issuer + issuer_id = self.env["l10n_mx_cfdi.issuer"].create( + { + "partner_id": partner_id.id, + } + ) + return issuer_id + + def action_generate_cfdi(self): + self.ensure_one() + + if self.cfdi_document_id.state == "published": + raise UserError(_("The CFDI has been published.")) + + if self.move_type == "out_invoice": + self.create_invoice_cfdi() + + if self.move_type == "out_refund": + # create credit note CFDI if required + if self.amount_residual != 0: + raise UserError( + _( + "You cannot generate a CFDI for a credit note with a " + "pending amount." + ) + ) + self.create_refund_cfdi() diff --git a/l10n_mx_cfdi/models/account_move_line.py b/l10n_mx_cfdi/models/account_move_line.py new file mode 100644 index 0000000..967e9c0 --- /dev/null +++ b/l10n_mx_cfdi/models/account_move_line.py @@ -0,0 +1,177 @@ +from odoo import api, fields, models +from odoo.tools import float_is_zero, float_round, json_float_round + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + cfdi_price_unit = fields.Monetary( + compute="_compute_cfdi_fields", + store=True, + ) + + cfdi_subtotal = fields.Monetary( + compute="_compute_cfdi_fields", + store=True, + ) + + cfdi_discount = fields.Monetary( + compute="_compute_cfdi_fields", + store=True, + ) + + @api.depends( + "product_id", + "price_unit", + "quantity", + "discount", + ) + def _compute_cfdi_fields(self): + for line in self: + line._gater_cfdi_item_data() + + def _gater_cfdi_item_data(self): + self.ensure_one() + + res = {} + currency = self.currency_id.sudo() + + # use product price decimal precision to round the price calculations + price_decimal_precision = self.env.ref("product.decimal_price").sudo().digits + + # Compute 'Subtotal'. + line_discount_price_unit = self.price_unit + + if hasattr(self, "discount_fixed"): + line_discount_price_unit -= self.discount_fixed + + line_discount_price_unit = line_discount_price_unit * ( + 1 - self.discount / 100.0 + ) + # round the price unit to the currency precision to prevent + # differences between the invoice totals and the CFDI total + line_discount_price_unit = float_round( + line_discount_price_unit, precision_digits=price_decimal_precision + ) + + subtotal = self.quantity * line_discount_price_unit + + # keep track of taxes included in price to subtract them later + # from the unit price as the CFDI specification doesn't support + # then + taxes_included = 0 + + cfdi_taxes = [] + if self.tax_ids: + # Compute taxes and adjust 'Subtotal' and 'Total' + taxes = self.tax_ids._origin.with_context(force_sign=1) + taxes_res = taxes.compute_all( + line_discount_price_unit, + quantity=self.quantity, + currency=self.currency_id, + product=self.product_id, + partner=self.partner_id, + is_refund=self.move_id.move_type in ("out_refund", "in_refund"), + ) + res["Subtotal"] = taxes_res["total_excluded"] + res["Total"] = taxes_res["total_included"] + + for computed_tax in taxes_res["taxes"]: + tax_id = self.env["account.tax"].browse(computed_tax["id"]) + tax_rate = ( + tax_id.amount / 100 + if tax_id.amount_type == "percent" + else tax_id.amount + ) + is_retention = tax_id.extract_is_retention() + tax_rate = json_float_round(tax_rate, precision_digits=6) + tax_total = json_float_round( + computed_tax["amount"], precision_digits=currency.decimal_places + ) + tax_base = json_float_round( + taxes_res["total_excluded"], + precision_digits=currency.decimal_places, + ) + + # sat expects retention taxes to be positive but odoo uses negative values + if is_retention: + tax_rate *= -1 + tax_total *= -1 + + cfdi_taxes.append( + { + "Name": tax_id.extract_l10n_mx_tax_code(), + "Rate": tax_rate, + "IsRetention": is_retention, + "Base": tax_base, + "Total": tax_total, + } + ) + + if tax_id.price_include: + taxes_included += tax_total + + if cfdi_taxes: + res.update( + { + "Taxes": cfdi_taxes, + "TaxObject": "02", # 'Si objeto de impuesto' + } + ) + else: + res["Total"] = res["Subtotal"] = subtotal + res["TaxObject"] = "01" + + if self.product_id.default_code: + res["IdentificationNumber"] = self.product_id.default_code + unit_included_taxes = taxes_included / (self.quantity or 1) + line_discount_price_unit -= unit_included_taxes + res.update( + { + "Quantity": self.quantity, + "ProductCode": self.product_id.l10n_mx_cfdi_product_code_id.code, + "Description": self.name, + "UnitCode": self.product_id.l10n_mx_cfdi_product_measurement_unit_id.code, + } + ) + + self._round_values_to_currency_precision(res) + + # compute discount + expected_subtotal_wo_discount = line_discount_price_unit * self.quantity + discount = ( + (self.price_unit * self.quantity) + - expected_subtotal_wo_discount + - taxes_included + ) + if float_is_zero(discount, precision_digits=currency.decimal_places): + # ignore a difference below the currency precision + discount = 0 + + res["Discount"] = discount + res["Subtotal"] += discount + + # recompute the unit price from the subtotal to avoid rounding + res["UnitPrice"] = res["Subtotal"] / (self.quantity or 1) + + self._round_values_to_currency_precision(res) + + # store the values to be used in the report + self.cfdi_subtotal = res["Subtotal"] + self.cfdi_discount = res["Discount"] + self.cfdi_price_unit = res["UnitPrice"] + + return res + + def _round_values_to_currency_precision(self, res, skip=None): + currency_decimal_places = self.currency_id.decimal_places + + # Round all values to the currency precision + for k, v in res.items(): + if skip and k in skip: + continue + + if isinstance(v, float): + res[k] = json_float_round(v, precision_digits=currency_decimal_places) + else: + res[k] = v diff --git a/l10n_mx_cfdi/models/account_payment.py b/l10n_mx_cfdi/models/account_payment.py new file mode 100644 index 0000000..e9b3eec --- /dev/null +++ b/l10n_mx_cfdi/models/account_payment.py @@ -0,0 +1,207 @@ +from datetime import datetime + +from odoo import _, models +from odoo.exceptions import ValidationError +from odoo.tools import json_float_round + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + def action_generate_cfdi(self): + self.ensure_one() + + if self.cfdi_document_id: + raise ValidationError(_("The payment already has a related CFDI.")) + + if not self.is_reconciled: + raise ValidationError(_("The payment is not fully reconciled.")) + + if self.payment_type == "inbound": + self.create_payment_cfdi() + + def create_payment_cfdi(self): + """ + Create CFDI of type payment ('P') matching the invoice payments if they are required. + """ + + self.ensure_one() + + # assert move type is inbound payment + if self.move_type != "entry" or self.payment_type != "inbound": + raise ValidationError(_("You can only create customer payments.")) + + # check if the payment is fully reconciled + if not self.is_reconciled: + raise ValidationError(_("The payment is not fully reconciled.")) + + payment_data = self.prepare_payment_cfdi() + + # get invoices issuer + issuer = self.reconciled_invoice_ids.issuer_id + issuer.ensure_one() + + # get invoices receiver + receiver = self.reconciled_invoice_ids.receiver_id + if not receiver: + # resolve receiver from the invoice CFDI for legacy invoices + receiver = self.reconciled_invoice_ids.related_cert_ids.filtered_domain( + [("type", "=", "I"), ("state", "=", "published")] + ).mapped("receiver_id")[0] + + receiver.ensure_one() + + payment_cfdi = self.env["l10n_mx_cfdi.document"].create( + { + "type": "P", + "issuer_id": issuer.id, + "receiver_id": receiver.id, + "related_payment_id": self.id, + } + ) + + # establecer uso de CFDI a Pagos (CP01) + self.cfdi_use_id = self.env.ref("l10n_mx_catalogs.c_uso_cfdi_CP01").id + + try: + cfdi_data = { + "ExpeditionPlace": issuer.zip, + "Receiver": { + "Name": receiver.name, + "Rfc": receiver.vat, + "CfdiUse": self.cfdi_use_id.code, + "FiscalRegime": receiver.tax_regime.code, + "TaxZipCode": receiver.zip, + }, + "Complemento": {"Payments": [payment_data]}, + } + + if receiver.vat == "XAXX010101000": + currentDateTime = datetime.now() + + cfdi_data["GlobalInformation"] = { + "Periodicity": "01", # Daily periodicity + "Months": str(currentDateTime.month).rjust(2, "0"), + "Year": currentDateTime.year, + } + + cfdi_data["Receiver"]["TaxZipCode"] = issuer.zip + cfdi_data["Receiver"]["FiscalRegime"] = "616" + + payment_cfdi.publish(cfdi_data) + + self.update( + { + "related_cert_ids": [(4, payment_cfdi.id)], + "cfdi_document_id": payment_cfdi.id, + } + ) + + for invoice in self.reconciled_invoice_ids: + invoice.related_cert_ids |= payment_cfdi + + except Exception as e: + payment_cfdi.unlink() + raise e + + def prepare_payment_cfdi(self): + self.ensure_one() + + related_documents_data = [] + + for invoice in self.reconciled_invoice_ids: + if not invoice.cfdi_document_id: + raise ValidationError( + _( + "Error al emitir CFDI tipo Comprobante de Pago. " + "La factura %s no tiene CFDI" + ) + % invoice.name + ) + + # get related cfdi of type 'Ingreso' + existent_invoice_cfdi = invoice.related_cert_ids.filtered_domain( + [("type", "=", "I"), ("state", "=", "published")] + ) + existent_invoice_cfdi.ensure_one() + + # get related CFDIs of type 'Pago' + existent_payments_cfdi = invoice.related_cert_ids.filtered_domain( + [("type", "=", "P"), ("state", "=", "published")] + ) + + # initialize related document data + related_document_data = { + "Uuid": existent_invoice_cfdi.uuid, + "Folio": existent_invoice_cfdi.name, + "PartialityNumber": len(existent_payments_cfdi) + 1, + "PaymentMethod": "PUE", + "AmountPaid": 0, + "PreviousBalanceAmount": invoice.amount_residual, + } + + # add amounts from matched credit lines + for credit in invoice.line_ids.matched_credit_ids: + # add line amount if it comes from the current payment + if credit.credit_move_id.move_id == self.move_id: + related_document_data["AmountPaid"] += credit.amount + related_document_data["PreviousBalanceAmount"] += credit.amount + + # add tax data + tax_data = self._compute_taxes(related_document_data["AmountPaid"], invoice) + if tax_data: + related_document_data["TaxObject"] = "02" + related_document_data["Taxes"] = tax_data + else: + related_document_data["TaxObject"] = "01" + + # round monetary fields + for field in ["AmountPaid", "PreviousBalanceAmount"]: + related_document_data[field] = json_float_round( + related_document_data[field], 2 + ) + + # add related document data to the list + related_documents_data.append(related_document_data) + + payment_date = self.move_id._format_cfdi_date_str(self.date) + payment_data = { + "Date": payment_date, + "PaymentForm": self.move_id.payment_form_id.code, + "Amount": json_float_round(self.amount, 2), + "RelatedDocuments": related_documents_data, + } + return payment_data + + def _compute_taxes(self, amount_paid, invoice): + total_taxes = invoice.prepare_invoice_cfdi_total_taxes() + payment_taxes = [] + + # skip if there are no taxes + if not total_taxes: + return payment_taxes + + # compute taxes base (amount_paid = rate * base) so ( base = amount_paid / rate ) + total_rate = sum(float(tax["Rate"] + 1) for tax in total_taxes) + base = amount_paid / total_rate + for tax in total_taxes: + payment_taxes.append( + { + "Name": tax["Name"], + "Rate": tax["Rate"], + "IsRetention": tax["IsRetention"], + "Base": json_float_round(base, 2), + "Total": json_float_round(base * float(tax["Rate"]), 2), + } + ) + + return payment_taxes + + def cancel_payment_cfdi(self): + self.ensure_one() + + for cfdi in self.related_cert_ids: + if cfdi.state == "published": + cfdi.cancel( + "02" + ) # cancel reason: 'Comprobantes emitidos con errores sin relación' diff --git a/l10n_mx_cfdi/models/account_tax.py b/l10n_mx_cfdi/models/account_tax.py new file mode 100644 index 0000000..e0e620e --- /dev/null +++ b/l10n_mx_cfdi/models/account_tax.py @@ -0,0 +1,20 @@ +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountTax(models.Model): + _inherit = "account.tax" + + def extract_l10n_mx_tax_code(self): + self.ensure_one() + if "ISR" in self.name: + return "ISR" + elif "IVA" in self.name: + return "IVA" + elif "IEPS" in self.name: + return "IEPS" + raise UserError(_("Cannot extract the tax code from %s") % self.name) + + def extract_is_retention(self): + self.ensure_one() + return "RET" in self.name diff --git a/l10n_mx_cfdi/models/cfdi_document.py b/l10n_mx_cfdi/models/cfdi_document.py new file mode 100644 index 0000000..48891eb --- /dev/null +++ b/l10n_mx_cfdi/models/cfdi_document.py @@ -0,0 +1,538 @@ +import base64 +import json +import re +from io import BytesIO + +import qrcode +from dateutil import parser + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class Document(models.Model): + _name = "l10n_mx_cfdi.document" + _description = "CFDI document" + + ### + # Certificate fields + ### + type = fields.Selection( + [ + ("I", "Ingreso"), + ("E", "Egreso"), + ("P", "Pago"), + ("T", "Traslado"), + ], + readonly=True, + ) + + version = fields.Char(default="4.0") + serie = fields.Char() + folio = fields.Char() + name = fields.Char( + string="Nombre", readonly=True, compute="_compute_name", store=True + ) + + uuid = fields.Char(string="UUID", readonly=True, help="UUID asignado por el SAT") + + issuer_id = fields.Many2one( + "l10n_mx_cfdi.issuer", + string="Emisor", + required=True, + domain=[("registered", "=", True)], + ) + + receiver_id = fields.Many2one("res.partner", string="Receptor", required=True) + + tracking_id = fields.Char(string="ID del documento en el API", readonly=True) + + pdf_file = fields.Binary(string="Archivo PDF", attachment=True, readonly=True) + xml_file = fields.Binary(string="Archivo XML", attachment=True, readonly=True) + + related_invoice_id = fields.Many2one( + "account.move", string="Factura relacionada", readonly=True + ) + related_payment_id = fields.Many2one( + "account.payment", string="Pago relacionado", readonly=True + ) + is_global_note = fields.Boolean(string="Nota global", readonly=True, default=False) + + ### + # Auxiliary fields + ### + state = fields.Selection( + [ + ("draft", "Borrador"), + ("pending", "Pendiente"), + ("published", "Publicada"), + ("pending_cancel", "Cancelación pendiente"), + ("canceled", "Cancelada"), + ("unknown", "Desconocido"), + ], + default="draft", + string="Estado", + readonly=True, + ) + + pdf_filename = fields.Char(string="Nombre del archivo PDF", readonly=True) + xml_filename = fields.Char(string="Nombre del archivo XML", readonly=True) + + cert_data_json = fields.Char(readonly=True) + + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + + standalone = fields.Boolean( + string="Independiente", + compute="_compute_standalone", + store=True, + help="Si está marcado, el certificado no esta relacionado " + "a otros documentos del sistema", + ) + + cancellation_request_proof_file = fields.Binary( + string="Acuse de solicitud de cancelación", attachment=True, readonly=True + ) + cancellation_request_proof_filename = fields.Char( + string="Nombre del archivo de acuse de solicitud de cancelación", readonly=True + ) + + # used to download the files on demand + files_in_cache = fields.Boolean( + readonly=True, compute="_compute_download_files_if_needed", store=False + ) + + issuing_datetime = fields.Datetime( + string="Fecha de emisión", readonly=True, compute="_compute_load_json_data" + ) + cert_number = fields.Char( + string="Número de certificado", readonly=True, compute="_compute_load_json_data" + ) + original_string = fields.Char( + string="Cadena original", readonly=True, compute="_compute_load_json_data" + ) + cfdi_signature = fields.Char( + string="Firma del CFDI", readonly=True, compute="_compute_load_json_data" + ) + sat_signature = fields.Char( + string="Firma del SAT", readonly=True, compute="_compute_load_json_data" + ) + sat_cert_number = fields.Char( + string="Número de certificado del SAT", + readonly=True, + compute="_compute_load_json_data", + ) + rfc_prov_certif = fields.Char( + string="RFC del proveedor de certificación", + readonly=True, + compute="_compute_load_json_data", + ) + signing_date = fields.Datetime( + string="Fecha de timbrado", readonly=True, compute="_compute_load_json_data" + ) + related_document_ids = fields.One2many( + "l10n_mx_cfdi.document_relation", "source_id", string="Documentos relacionados" + ) + tax_codes = fields.Char( + string="Código de impuesto", readonly=True, compute="_compute_load_json_data" + ) + + verification_url = fields.Char( + string="URL de verificación", readonly=True, compute="_compute_load_json_data" + ) + verification_qr_code = fields.Binary( + string="Código QR de Verificación", + readonly=True, + compute="_compute_load_json_data", + ) + + # utility fields + l10n_mx_cfdi_auto = fields.Boolean( + string="CFDI Automatico", related="company_id.l10n_mx_cfdi_auto", readonly=True + ) + + l10n_mx_cfdi_enabled = fields.Boolean( + string="CFDI Habilitado", + related="company_id.l10n_mx_cfdi_enabled", + readonly=True, + ) + + @api.depends("cert_data_json") + def _compute_load_json_data(self): + for rec in self: + if rec.cert_data_json: + data = json.loads(rec.cert_data_json) + rec.issuing_datetime = parser.parse(data["Date"]) + rec.cert_number = data["CertNumber"] + rec.original_string = data["OriginalString"] + rec.cfdi_signature = data["Complement"]["TaxStamp"]["CfdiSign"] + rec.sat_signature = data["Complement"]["TaxStamp"]["SatSign"] + rec.sat_cert_number = data["Complement"]["TaxStamp"]["SatCertNumber"] + rec.rfc_prov_certif = data["Complement"]["TaxStamp"]["RfcProvCertif"] + rec.signing_date = parser.parse(data["Complement"]["TaxStamp"]["Date"]) + rec.verification_url = self._generate_verification_url( + rec.uuid, + rec.issuer_id.vat, + rec.receiver_id.vat, + data["Total"], + rec.cfdi_signature[-8:], + ) + rec.verification_qr_code = self._generate_qr_code( + rec.verification_url.encode("utf-8") + ) + rec.tax_codes = self._load_tax_code_from_json_data(data) + + @api.model + def _load_tax_code_from_json_data(self, data): + taxes = set() + if "Taxes" in data: + for tax in data["Taxes"]: + if tax["Name"] == "ISR": + taxes.add("001") + if tax["Name"] == "IVA": + taxes.add("002") + if tax["Name"] == "IEPS": + taxes.add("003") + + return ",".join(taxes) + + @api.depends("pdf_file", "xml_file") + def _generate_verification_url( + self, uuid, issuer_cfdi, receiver_cfdi, total, sign_extract + ): + url = ( + f"https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?" + f"id={uuid}&" + f"re={issuer_cfdi}&" + f"rr={receiver_cfdi}&" + f"tt={total}&" + f"fe={sign_extract}" + ) + return url + + def _generate_qr_code(self, data): + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_Q, + box_size=4, + border=0, + ) + + qr.add_data(data) + img = qr.make_image(fit=True) + temp = BytesIO() + img.save(temp, format="PNG") + qr_image = base64.b64encode(temp.getvalue()) + return qr_image + + ### + # Computed fields generation functions + ### + + @api.depends("tracking_id") + def _compute_download_files_if_needed(self): + for entry in self: + if entry.tracking_id: + if not entry.pdf_file: + report, resource_ids = self._resolve_report() + + if report: + # force the report to be rendered to work around a bug + # in _render_qweb_pdf + report = report.with_context(**{"force_report_rendering": True}) + doc_data, doc_format = report._render_qweb_pdf(resource_ids) + # in some scenarios, the report is not generated, + # so we need to check if the file is empty + if doc_data: + result = base64.b64encode(doc_data) + entry.pdf_file = result + + if not entry.pdf_file: + # fallback to the provider's PDF + res = entry.issuer_id.service_id.sudo().get_cfdi_pdf( + entry.tracking_id + ) + entry.pdf_file = res["Content"] + + # set filename + entry.pdf_filename = "%s.pdf" % entry.name + + if not entry.xml_file: + res = entry.issuer_id.service_id.sudo().get_cfdi_xml( + entry.tracking_id + ) + entry.xml_file = res["Content"].encode("utf-8") + entry.xml_filename = "%s.xml" % entry.name + + entry.files_in_cache = True + else: + entry.files_in_cache = False + + def _resolve_report(self): + """Returns the report and the resource ids to be used to generate the PDF file.""" + report = None + resource_ids = [] + + if self.type in ("I", "E") and self.related_invoice_id: + report = self.env.ref("account.account_invoices") + resource_ids = [self.related_invoice_id.id] + + if self.type == "P" and self.related_payment_id: + report = self.env.ref("account.action_report_payment_receipt") + resource_ids = [self.related_payment_id.id] + + return report, resource_ids + + @api.depends("serie", "folio") + def _compute_name(self): + for entry in self: + if entry.serie: + entry.name = "%s-%s" % (entry.serie, entry.folio) + else: + entry.name = "%s" % entry.folio + + @api.depends("type") + def _compute_standalone(self): + # only documents of type 'T' are considered standalone + for entry in self: + entry.standalone = entry.type == "T" + + ### + # Model methods + ### + + def create(self, vals_list): + # Set values to serie and folio from sequence if not provided + + # check if vals_list is a list of dictionaries + if isinstance(vals_list, dict): + vals_list = [vals_list] + + for vals in vals_list: + if "serie" not in vals or "folio" not in vals: + issuer = self._resolve_issuer_on_create(vals) + if ( + issuer.use_origin_document_sequence + and vals.get("type", False) != "T" + and vals.get("is_global_note", False) is False + ): + self._set_serie_and_folio_from_document_sequence(vals) + else: + self._set_serie_and_folio_from_cfdi_sequence(vals) + + # Create certificate + return super().create(vals_list) + + def _resolve_issuer_on_create(self, vals): + issuer_id = vals.get("issuer_id", False) + if not issuer_id: + raise UserError(_("Issuer is required to generate a new document.")) + + return self.env["l10n_mx_cfdi.issuer"].browse(issuer_id) + + def _set_serie_and_folio_from_cfdi_sequence(self, vals): + sequence_id = self.get_sequence_for_cfdi_type(vals) + + vals["serie"] = sequence_id.prefix + vals["folio"] = sequence_id.number_next + + sequence_id.next_by_id(sequence_id.id) + + def _set_serie_and_folio_from_document_sequence(self, vals): + serie = "" + folio = "" + document_name = "" + + if "related_invoice_id" in vals: + invoice = self.env["account.move"].browse(vals["related_invoice_id"]) + document_name = invoice.name + + if "related_payment_id" in vals: + payment = self.env["account.payment"].browse(vals["related_payment_id"]) + document_name = payment.name + + if not document_name: + raise UserError(_("Unable to determine the origin document name.")) + + # extract numeric postfix from invoice name using regex + match = re.search(r"\d+$", document_name) + if match: + folio = match.group() + serie = document_name[: -len(match.group())] + else: + raise UserError(_("Invoice name does not contain a numeric postfix.")) + + # remove non-alphanumeric characters from serie + serie = re.sub(r"\W+", "", serie) + + vals["serie"] = serie + vals["folio"] = folio + + @api.model + def get_sequence_for_cfdi_type(self, vals_list): + issuer_id = self.env["l10n_mx_cfdi.issuer"].browse(vals_list["issuer_id"]) + + if vals_list["type"] == "I": + return issuer_id.invoice_sequence_id + elif vals_list["type"] == "E": + return issuer_id.refund_sequence_id + elif vals_list["type"] == "P": + return issuer_id.payment_sequence_id + elif vals_list["type"] == "T": + return issuer_id.transfer_sequence_id + else: + raise UserError(_("Type of certificate unknown.")) + + def cancel(self, reason: str, replacement=None, simulate=False): + self.ensure_one() + + if self.state != "published": + return + + if not simulate: + res = self.issuer_id.service_id.sudo().cancel_cfdi( + self.tracking_id, reason, replacement + ) + if ( + res["Status"] == "canceled" + or res["Status"] == "acepted" + or res["Status"] == "expired" + ): + self.state = "canceled" + self.pdf_file = False + self.xml_file = False + elif res["Status"] == "pending": + self.state = "pending_cancel" + elif res["Status"] == "rejected": + self.state = "published" + else: + raise UserError( + _("Error when cancelling the certificate: %s") % res["Message"] + ) + else: + self.state = "canceled" + + def publish(self, cfdi_data): + self.ensure_one() + + if "Serie" not in cfdi_data: + cfdi_data["Serie"] = self.serie + + if "Folio" not in cfdi_data: + cfdi_data["Folio"] = self.folio + + if "CfdiType" not in cfdi_data: + cfdi_data["CfdiType"] = self.type + + if "Issuer" not in cfdi_data: + cfdi_data["Issuer"] = { + "Name": ( + self.issuer_id.fiscal_name + if hasattr(self.issuer_id, "fiscal_name") + else self.issuer_id.name + ), + "Rfc": self.issuer_id.vat, + "FiscalRegime": self.issuer_id.tax_regime.code, + } + + if "LogoUrl" not in cfdi_data and self.issuer_id.logo_url: + cfdi_data["LogoUrl"] = self.issuer_id.logo_url + + for entry in self: + if entry.state != "draft": + raise UserError(_("The certificate is not in draft.")) + + # check if there are no other published certificates with the same serie and folio + similar_certificates_count = self.search( + [ + ("serie", "=", entry.serie), + ("folio", "=", entry.folio), + ("state", "=", "published"), + ], + count=True, + ) + + if similar_certificates_count > 0: + raise UserError( + _( + "A certificate is already published with this serie " + "and number." + ) + ) + + # use sudo to allow users to publish certificates + res = entry.issuer_id.service_id.sudo().create_cfdi(cfdi_data) + + # store result for later usage + self.cert_data_json = json.dumps(res) + + if res["Status"] == "active": + self.uuid = res["Complement"]["TaxStamp"]["Uuid"] + self.tracking_id = res["Id"] + self.state = "published" + else: + raise UserError( + _("Error when publishing the certificate: %s") % res["Message"] + ) + + def action_cancel(self): + self.ensure_one() + + return { + "name": "Cancel certificate", + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "l10n_mx_cfdi.document_cancel", + "target": "new", + "context": {"default_certificate_ids": [(6, 0, [self.id])]}, + } + + def action_check_status(self): + self.ensure_one() + + service = self.issuer_id.service_id.sudo() + amount_total = 0 + if self.related_invoice_id: + amount_total = self.related_invoice_id.amount_total + + if self.related_payment_id: + amount_total = self.related_payment_id.amount + + status = service.check_cfdi_status( + self.uuid, self.issuer_id.vat, self.receiver_id.vat, amount_total + ) + + if self.state != status: + self.state = status + + def action_get_cancellation_request_proof(self): + self.ensure_one() + + # check that the certificate is canceled + if self.state != "canceled": + raise UserError(_("The certificate is not cancelled.")) + + service = self.issuer_id.service_id.sudo() + + file = service.get_cancellation_request_proof(self.tracking_id) + self.cancellation_request_proof_file = file + self.cancellation_request_proof_filename = ( + "Solicitud de cancelación %s.pdf" % self.name + ) + + +class DocumentRelation(models.Model): + _name = "l10n_mx_cfdi.document_relation" + _description = "Describe a relation between two CFDIs" + + relation_type_id = fields.Many2one( + "l10n_mx_catalogs.c_tipo_relacion", required=True + ) + source_id = fields.Many2one( + "l10n_mx_cfdi.document", required=True, ondelete="cascade" + ) + target_id = fields.Many2one( + "l10n_mx_cfdi.document", required=True, ondelete="cascade" + ) diff --git a/l10n_mx_cfdi/models/cfdi_issuer.py b/l10n_mx_cfdi/models/cfdi_issuer.py new file mode 100644 index 0000000..1e867e7 --- /dev/null +++ b/l10n_mx_cfdi/models/cfdi_issuer.py @@ -0,0 +1,134 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class CFDIIssuer(models.Model): + """Holds the CFDI issuer information""" + + _name = "l10n_mx_cfdi.issuer" + _description = "Emisor" + + # Embed partner fields + partner_id = fields.Many2one( + "res.partner", delegate=True, ondelete="cascade", required=True + ) + + logo_url = fields.Char(string="URL del logo") + fiscal_name = fields.Char(help="Razón Social del Emisor") + certificate_file = fields.Binary(groups="account.group_account_manager") + key_file = fields.Binary(groups="account.group_account_manager") + key_password = fields.Char( + string="Password", groups="account.group_account_manager" + ) + service_id = fields.Many2one("l10n_mx_cfdi.cfdi_service") + registered = fields.Boolean(store=True) + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + use_origin_document_sequence = fields.Boolean( + string="Usar Serie de Origen", + help="Usar serie del documento de origen para los CFDI generados", + ) + + invoice_sequence_id = fields.Many2one( + "l10n_mx_cfdi.series", + string="Serie Ingresos", + default=lambda self: self._create_default_cfdi_sequence("Ingresos"), + ) + refund_sequence_id = fields.Many2one( + "l10n_mx_cfdi.series", + string="Serie Egresos", + default=lambda self: self._create_default_cfdi_sequence("Egresos"), + ) + transfer_sequence_id = fields.Many2one( + "l10n_mx_cfdi.series", + string="Serie Traslados", + default=lambda self: self._create_default_cfdi_sequence("Traslados"), + ) + payment_sequence_id = fields.Many2one( + "l10n_mx_cfdi.series", + string="Serie Pagos", + default=lambda self: self._create_default_cfdi_sequence("Pagos"), + ) + + @api.model + def default_get(self, fields_list): + # set country to Mexico + res = super().default_get(fields_list) + res["country_id"] = self.env.ref("base.mx").id + + return res + + @api.model + def _slugify(self, string): + # slugify string + return string.lower().replace(" ", "_") + + @api.model + def _create_default_cfdi_sequence(self, name): + # format a unique sequence code for the company + sequence_code = "l10n_mx_cfdi.sequence.{}.{}".format( + self._slugify(self.env.company.name), self._slugify(name) + ) + + existent_sequence = self.env["l10n_mx_cfdi.series"].search( + [("code", "=", sequence_code)] + ) + if existent_sequence: + return existent_sequence + else: + return ( + self.env["l10n_mx_cfdi.series"] + .sudo() + .create( + { + "name": "Folios CFDI %s" % name, + "implementation": "no_gap", + "number_increment": 1, + "number_next_actual": 0, + "prefix": name[0], + "company_id": self.env.user.company_id.id, + "code": sequence_code, + } + ) + ) + + def register_issuer(self): + """Registers the certificate in the SAT""" + + self.ensure_one() + if not self.vat: + raise UserError(_("Emitter RFC not defined..")) + + if not self.certificate_file or not self.key_file or not self.key_password: + raise UserError(_("Digital certificate not configured.")) + + if not self.service_id: + raise UserError(_("Invoicing service not defined.")) + + try: + self.service_id.register_csd( + self.vat, self.certificate_file, self.key_file, self.key_password + ) + self.registered = True + except Exception as e: + self.registered = False + _logger.warning(e) + raise UserError(_("Cannot register the certificate.")) from e + + def unregister_issuer(self): + """Unregisters the certificate in the SAT""" + self.ensure_one() + if not self.certificate_file or not self.key_file or not self.key_password: + self.registered = False + return + if not self.service_id: + raise UserError(_("Invoicing service not defined.")) + try: + self.service_id.unregister_csd(self.vat) + self.registered = False + except Exception as e: + _logger.warning(e) + raise UserError(_("Cannot unregister the certificate.")) from e diff --git a/l10n_mx_cfdi/models/cfdi_series.py b/l10n_mx_cfdi/models/cfdi_series.py new file mode 100644 index 0000000..c34c1f5 --- /dev/null +++ b/l10n_mx_cfdi/models/cfdi_series.py @@ -0,0 +1,16 @@ +from odoo import api, fields, models + + +class CFDISeries(models.Model): + _name = "l10n_mx_cfdi.series" + _inherit = ["ir.sequence"] + _description = "CFDI Series" + + code = fields.Char(copy=False) + + # override create method to set the code + @api.model + def create(self, vals): + if not vals.get("implementation"): + vals["implementation"] = "no_gap" + return super().create(vals) diff --git a/l10n_mx_cfdi/models/cfdi_service.py b/l10n_mx_cfdi/models/cfdi_service.py new file mode 100644 index 0000000..f799812 --- /dev/null +++ b/l10n_mx_cfdi/models/cfdi_service.py @@ -0,0 +1,190 @@ +import json +import logging + +import facturama + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class CFDIService(models.Model): + _name = "l10n_mx_cfdi.cfdi_service" + _description = "CFDI Service Settings" + + name = fields.Char(string="Nombre", required=True) + company_ids = fields.Many2many( + "res.company", string="Compañías", default=lambda self: self.env.company + ) + user = fields.Char(required=True, groups="base.group_system") + password = fields.Char(required=True, groups="base.group_system") + sandbox_mode = fields.Boolean(default=False, groups="base.group_system") + topup_ids = fields.One2many( + "l10n_mx_cfdi.cfdi_service.topup", "service_id", string="Recargas" + ) + stamps_used = fields.Integer(readonly=True, compute="_compute_stamps_used") + + def _compute_stamps_used(self): + for record in self: + if self.usage_sequence_id: + record.stamps_used = self.usage_sequence_id.number_next_actual - 1 + else: + record.stamps_used = 0 + + stamps_available = fields.Integer( + string="Folios Disponibles", readonly=True, compute="_compute_stamps_available" + ) + + def _compute_stamps_available(self): + for record in self: + total_stamps_acquired = sum(record.topup_ids.mapped("stamp_number")) + record.stamps_available = total_stamps_acquired - record.stamps_used + + def _create_usage_sequence(self): + return self.env["ir.sequence"].create( + { + "name": "CFDI Folios Usados", + "implementation": "no_gap", + "number_increment": 1, + "number_next_actual": 0, + "company_id": self.env.user.company_id.id, + "code": "l10n_mx_cfdi.cfdi_service.usage", + } + ) + + usage_sequence_id = fields.Many2one( + "ir.sequence", + string="Secuencia de uso de Folios", + default=_create_usage_sequence, + ondelete="cascade", + ) + + @api.ondelete(at_uninstall=False) + def _unlink_related_usage_sequence(self): + self.usage_sequence_id.unlink() + + def _get_client(self): + self.ensure_one() + + facturama.sandbox = True if self.sandbox_mode else False + facturama._credentials = (self.user, self.password) + return facturama + + def register_csd( + self, rfc: str, cert_base64: bytes, key_base64: bytes, key_password: str + ): + self.ensure_one() + + client = self._get_client() + try: + client.csdsMultiEmisor.build_http_request( + "post", + "csds", + { + "Rfc": str(rfc).upper(), + "Certificate": cert_base64.decode("utf-8"), + "PrivateKey": key_base64.decode("utf-8"), + "PrivateKeyPassword": key_password, + }, + version=2, + ) + except facturama.MalformedRequestError as e: + error_message = str(e.error_json["Message"]) + "\n" + if "ModelState" in e.error_json: + model_state = e.error_json["ModelState"] + for entry in model_state: + error_message += str(model_state[entry]) + "\n" + raise UserError(error_message) from e + + def unregister_csd(self, rfc): + self.ensure_one() + + client = self._get_client() + client.csdsMultiEmisor.delete(rfc) + + def get_csd_status(self, rfc: str): + self.ensure_one() + + client = self._get_client() + return client.csdsMultiEmisor.get_by_rfc(rfc) + + def create_cfdi(self, cfdi_data: dict): + self.ensure_one() + if not self.stamps_available: + raise UserError( + _( + "No hay folios disponibles para emitir CFDIs.\n" + "Comuníquese con el administrador del sistema." + ) + ) + + client = self._get_client() + try: + res = client.CfdiMultiEmisor.build_http_request( + "post", "cfdis", cfdi_data, version=6 + ) + if "Id" in res: + _logger.info("CFDI creado: %s", res["Id"]) + self.usage_sequence_id.next_by_id() + return res + except facturama.MalformedRequestError as e: + error_message = ( + _("Error when creating the CFDI: %s\n") % e.error_json["Message"] + ) + if "ModelState" in e.error_json: + model_state = e.error_json["ModelState"] + error_message += json.dumps(model_state, indent=4) + "\n" + + raise UserError(error_message) from e + except facturama.ApiError as e: + logging.error(e) + raise UserError( + _("Ocurrió un error con el servicio de facturación.\n") + ) from e + + def get_cfdi_pdf(self, cfdi_id: str): + client = self._get_client() + return client.CfdiMultiEmisor.get_by_file("pdf", "IssuedLite", cfdi_id) + + def get_cfdi_xml(self, cfdi_id: str): + client = self._get_client() + return client.CfdiMultiEmisor.get_by_file("xml", "IssuedLite", cfdi_id) + + def cancel_cfdi(self, cfdi_id: str, reason, uuid_replacement): + client = self._get_client() + _logger.info("Cancelando CFDI %s", cfdi_id) + return client.CfdiMultiEmisor.delete(cfdi_id, reason, uuid_replacement) + + def get_cancellation_request_proof(self, cfdi_id: str): + client = self._get_client() + res = client.CfdiMultiEmisor.build_http_request( + "get", f"acuse/pdf/issuedLite/{cfdi_id}" + ) + return res["Content"] + + def check_cfdi_status(self, uudi, issuer_rfc, receiver_rfc, amount_total): + client = self._get_client() + _logger.info("Consultando estado de CFDI %s", uudi) + res = client.CfdiMultiEmisor.build_http_request( + "get", + "cfdi/status", + params={ + "uuid": uudi, + "issuerRfc": issuer_rfc, + "receiverRfc": receiver_rfc, + "total": amount_total, + }, + ) + status = res["Status"] + _logger.info("Estado de CFDI %s: %s", uudi, status) + + # mapping of status + if status == "Vigente": + return "published" + + if status == "Cancelado": + return "cancelled" + + if status == "No Encontrado": + return "unknown" diff --git a/l10n_mx_cfdi/models/cfdi_service_topup.py b/l10n_mx_cfdi/models/cfdi_service_topup.py new file mode 100644 index 0000000..128378b --- /dev/null +++ b/l10n_mx_cfdi/models/cfdi_service_topup.py @@ -0,0 +1,48 @@ +from odoo import api, fields, models + + +class CFDIServiceTopUp(models.Model): + _name = "l10n_mx_cfdi.cfdi_service.topup" + _description = "CFDI Service Top Up" + + topup_date = fields.Datetime( + string="Fecha de Adquisición", default=fields.Datetime.now() + ) + stamp_number = fields.Integer(string="Cantidad de Folios", required=True) + stamp_price = fields.Monetary( + string="Precio por Folio", required=True, currency_field="currency_id" + ) + total = fields.Monetary( + string="Precio Total", + compute="_compute_total", + store=True, + currency_field="currency_id", + ) + + service_id = fields.Many2one( + "l10n_mx_cfdi.cfdi_service", + string="Servicio", + required=True, + ondelete="cascade", + ) + partner_id = fields.Many2one( + "res.partner", + string="Contacto", + required=True, + readonly=True, + ondelete="restrict", + default=lambda self: self.env.user.partner_id, + ) + + currency_id = fields.Many2one( + "res.currency", + string="Moneda", + required=True, + readonly=True, + default=lambda self: self.env.company.currency_id, + ) + + @api.depends("stamp_number", "stamp_price") + def _compute_total(self): + for record in self: + record.total = record.stamp_number * record.stamp_price diff --git a/l10n_mx_cfdi/models/product_template.py b/l10n_mx_cfdi/models/product_template.py new file mode 100644 index 0000000..b428451 --- /dev/null +++ b/l10n_mx_cfdi/models/product_template.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + # Add CFDI required product code and measure unit to product template + # reference: https://www.cfdi.org.mx/catalogos-de-cfdi/productos-y-servicios/ + _inherit = "product.template" + + l10n_mx_cfdi_product_code_id = fields.Many2one( + "l10n_mx_catalogs.c_clave_prod_serv", string="Código de Producto" + ) + + l10n_mx_cfdi_product_measurement_unit_id = fields.Many2one( + "l10n_mx_catalogs.c_clave_unidad", string="Unidad de Medida" + ) diff --git a/l10n_mx_cfdi/models/res_company.py b/l10n_mx_cfdi/models/res_company.py new file mode 100644 index 0000000..bea0589 --- /dev/null +++ b/l10n_mx_cfdi/models/res_company.py @@ -0,0 +1,21 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + l10n_mx_cfdi_auto = fields.Boolean( + "Create CFDI on post", + default=True, + help="Enable to automatically sign CFDI when validating invoices.", + ) + + l10n_mx_cfdi_enabled = fields.Boolean( + "Enable CFDI", + compute="_compute_l10n_mx_cfdi_enabled", + help="Enable CFDI for this company.", + ) + + def _compute_l10n_mx_cfdi_enabled(self): + for company in self: + company.l10n_mx_cfdi_enabled = company.country_id == self.env.ref("base.mx") diff --git a/l10n_mx_cfdi/models/res_config_settings.py b/l10n_mx_cfdi/models/res_config_settings.py new file mode 100644 index 0000000..cd408ff --- /dev/null +++ b/l10n_mx_cfdi/models/res_config_settings.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + l10n_mx_cfdi_auto = fields.Boolean( + "Create CFDI on post", + related="company_id.l10n_mx_cfdi_auto", + help="Enable to automatically sign CFDI when validating invoices.", + readonly=False, + ) diff --git a/l10n_mx_cfdi/models/res_partner.py b/l10n_mx_cfdi/models/res_partner.py new file mode 100644 index 0000000..be5cfb8 --- /dev/null +++ b/l10n_mx_cfdi/models/res_partner.py @@ -0,0 +1,19 @@ +from odoo import fields, models + + +class Partner(models.Model): + _inherit = "res.partner" + + # Add a new field to the res.partner model, called tax_regime with SAT tax regime + # source https://www.cfdi.org.mx/catalogos-de-cfdi/regimen-fiscal/ + tax_regime = fields.Many2one( + "l10n_mx_catalogs.c_regimen_fiscal", string="Fiscal Regime" + ) + + cfdi_use_id = fields.Many2one("l10n_mx_catalogs.c_uso_cfdi", string="CFDI Usage") + payment_method_id = fields.Many2one( + "l10n_mx_catalogs.c_metodo_pago", string="Payment Method" + ) + payment_form_id = fields.Many2one( + "l10n_mx_catalogs.c_forma_pago", string="Payment Form" + ) diff --git a/l10n_mx_cfdi/readme/CONTRIBUTORS.rst b/l10n_mx_cfdi/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..8b219b0 --- /dev/null +++ b/l10n_mx_cfdi/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Alexis López Zubieta +* Maxime Chambreuil diff --git a/l10n_mx_cfdi/readme/DESCRIPTION.rst b/l10n_mx_cfdi/readme/DESCRIPTION.rst new file mode 100644 index 0000000..49d2440 --- /dev/null +++ b/l10n_mx_cfdi/readme/DESCRIPTION.rst @@ -0,0 +1,25 @@ +============ +l10n_mx_cfdi +============ + +This module provides electronic invoicing for Mexico (CFDI 4.0) using Facturama as the only PAC (for now) + +Features +-------- +- Generation of electronic invoices compliant with the CFDI 4.0 standard. +- Integration with Facturama (for now) as the PAC for the issuance and stamping of invoices. +- Customization of fiscal documents according to user needs. +- Centralized management of electronic invoices within Odoo. +- Tracking and recording of issued and received fiscal documents. + +System Requirements +------------------- +- Odoo 15.0 +- Active account in Facturama +- Pre-configuration of fiscal and company data in Odoo. + +Installation +------------ +1. Log in to Odoo as an administrator and navigate to the applications section. +2. Search for "l10n_mx_cfdi" and click install. +3. Configure module settings by entering access credentials for Facturama and other required details. diff --git a/l10n_mx_cfdi/readme/USAGE.rst b/l10n_mx_cfdi/readme/USAGE.rst new file mode 100644 index 0000000..914b6f0 --- /dev/null +++ b/l10n_mx_cfdi/readme/USAGE.rst @@ -0,0 +1,9 @@ +============ +l10n_mx_cfdi +============ + +This module provides electronic invoicing for Mexico (CFDI 4.0) using Facturama as the only PAC (for now) + +Usage +----- + diff --git a/l10n_mx_cfdi/reports/report_cfdi_blocks.xml b/l10n_mx_cfdi/reports/report_cfdi_blocks.xml new file mode 100644 index 0000000..a0a0180 --- /dev/null +++ b/l10n_mx_cfdi/reports/report_cfdi_blocks.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + diff --git a/l10n_mx_cfdi/reports/report_external_layouts.xml b/l10n_mx_cfdi/reports/report_external_layouts.xml new file mode 100644 index 0000000..206f0d8 --- /dev/null +++ b/l10n_mx_cfdi/reports/report_external_layouts.xml @@ -0,0 +1,423 @@ + + + + + + + + + + + diff --git a/l10n_mx_cfdi/reports/report_invoice.xml b/l10n_mx_cfdi/reports/report_invoice.xml new file mode 100644 index 0000000..1b44230 --- /dev/null +++ b/l10n_mx_cfdi/reports/report_invoice.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + diff --git a/l10n_mx_cfdi/reports/report_payment.xml b/l10n_mx_cfdi/reports/report_payment.xml new file mode 100644 index 0000000..c3134a8 --- /dev/null +++ b/l10n_mx_cfdi/reports/report_payment.xml @@ -0,0 +1,134 @@ + + + + + + + diff --git a/l10n_mx_cfdi/security/ir.model.access.csv b/l10n_mx_cfdi/security/ir.model.access.csv new file mode 100644 index 0000000..f32f112 --- /dev/null +++ b/l10n_mx_cfdi/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_l10n_mx_cfdi_issuer,access_l10n_mx_cfdi_issuer,model_l10n_mx_cfdi_issuer,base.group_user,1,0,0,0 +manage_l10n_mx_cfdi_issuer,manage_l10n_mx_cfdi_issuer,model_l10n_mx_cfdi_issuer,account.group_account_manager,1,1,1,1 +access_l10n_mx_cfdi_cfdi_service,access_l10n_mx_cfdi_cfdi_service,l10n_mx_cfdi.model_l10n_mx_cfdi_cfdi_service,base.group_user,1,0,0,0 +manage_l10n_mx_cfdi_cfdi_service,manage_l10n_mx_cfdi_cfdi_service,l10n_mx_cfdi.model_l10n_mx_cfdi_cfdi_service,base.group_system,1,1,1,1 +l10n_mx_cfdi.access_l10n_mx_cfdi_cfdi_service_topup,access_l10n_mx_cfdi_cfdi_service_topup,l10n_mx_cfdi.model_l10n_mx_cfdi_cfdi_service_topup,base.group_user,1,0,0,0 +l10n_mx_cfdi.admin_l10n_mx_cfdi_cfdi_service_topup,admin_l10n_mx_cfdi_cfdi_service_topup,l10n_mx_cfdi.model_l10n_mx_cfdi_cfdi_service_topup,base.group_system,1,1,1,1 +access_l10n_mx_cfdi_document,Acceso a certificados 4.0,model_l10n_mx_cfdi_document,base.group_user,1,1,1,1 +cancel_l10n_mx_cfdi_document,Cancelar a certificados,model_l10n_mx_cfdi_document_cancel,base.group_user,1,1,1,1 +l10n_mx_cfdi.access_l10n_mx_cfdi_document_relation,access_l10n_mx_cfdi_document_relation,l10n_mx_cfdi.model_l10n_mx_cfdi_document_relation,base.group_user,1,1,1,1 +l10n_mx_cfdi.access_l10n_mx_cfdi_generic_invoice_create,access_l10n_mx_cfdi_generic_invoice_create,l10n_mx_cfdi.model_l10n_mx_cfdi_generic_invoice_create,base.group_user,1,1,1,1 +l10n_mx_cfdi.access_l10n_mx_cfdi_series,access_l10n_mx_cfdi_series,l10n_mx_cfdi.model_l10n_mx_cfdi_series,base.group_user,1,0,0,0 +l10n_mx_cfdi.manage_l10n_mx_cfdi_series,manage_l10n_mx_cfdi_series,l10n_mx_cfdi.model_l10n_mx_cfdi_series,account.group_account_manager,1,1,1,1 +l10n_mx_cfdi.access_l10n_mx_cfdi_download_cfdi_files_wizard,access_l10n_mx_cfdi_download_cfdi_files_wizard,l10n_mx_cfdi.model_l10n_mx_cfdi_download_cfdi_files_wizard,base.group_user,1,1,1,1 diff --git a/l10n_mx_cfdi/security/l10n_mx_cfdi_security.xml b/l10n_mx_cfdi/security/l10n_mx_cfdi_security.xml new file mode 100644 index 0000000..a8abf4c --- /dev/null +++ b/l10n_mx_cfdi/security/l10n_mx_cfdi_security.xml @@ -0,0 +1,55 @@ + + + + Document CFDI Admin Access + + [(1,'=',1)] + + + + + + Document CFDI Company Access + + ['|',('company_id', '=', False),('company_id', 'in', company_ids)] + + + + + + Issuer CFDI Admin Access + + [(1,'=',1)] + + + + + + Issuer CFDI Company Access + + ['|',('company_id', '=', False),('company_id', 'in', company_ids)] + + + + + + CFDI Service Admin Access + + [(1,'=',1)] + + + + + CFDI Service Company Access + + ['|',('company_ids', '=', False),('company_ids', 'in', company_ids)] + + + + diff --git a/l10n_mx_cfdi/static/description/icon.png b/l10n_mx_cfdi/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..70392fd3e60441f5b7e2a84f72b92b790080401c GIT binary patch literal 3050 zcmV!Oo;QS zTX&k&O{VGeF_}*P=uF*aI@6i_(ayB}ukB2lw39!YOs9>L%$RsGagvGSynxuoNgGHE z=3zkCNKos6gic6sI^A6dlo3Lbu^xMHhxrVDaHqY!y{~qEyT9ME(!_!ZeYsDco(TI#}J1>%&C4-V(!+ zEE5k_mPhdn<&*;%igl^T>&jZb{r9x}?7Kh7^+9I;_!r(an`Po7W8;5)^ub(ju`y(5 zyit^wRS|atf-)#YX0~l#L1d7R=jB!V zS3T1c8}uBT1pLuF&vT6XhG*!5k@GL#7{95v??3j;pG(W_3K1kXAj`)vZr{zTH%|Y_ zKRY%thpNz6&&9sc@yDP2*M}OtDer9S8`h}Lah!8{hT~!|id+zo<)bJ%I)A0Eou25a z?jHK8UJnpn^xqhN@A+p>{O%ulXIo!yOFLs97#O_yKTp9$E(n;mj|+_mQNOyZ%@aEs z2rSIaEsnZ*dwEHDg>LM#cbyZ%5bs40_<;k>2M#t9#AZelV9JNnb$wuYiq1kp6co!ee&_!kyZ_TlKTE@e>nA~{jjy^{s%ug^UB_8ow|s9`2HWFD4Iw9 z9o_gig%YC1voUgC9-FIJWQIWd9C(06N zT}Ny4<0S8%c70{2X{u>`_^sMtTa`&q?hct-I`6TM&kyat`-pAV;rueEi|FJ-6h+nQ zA_PJD`Yv~Mone?z@~5RnLwm=)8`GaalaHh{dTYnKA3XmZZJ@o_SIb=bZTR&zm7{)F ztBtt%{>LvEEUf_KS^H8z5k#xi>GcKxfXnGR^~$Tm!z0N_;{8c;OOw?73Cw{@nYr%i zr(W>f9MN4p{YBF=XSFAbY)2!#Ck^_Fpa1j?y{;VMy;g?kDx=X%k|fKr@1Hw=_Ut<; z{mIUqR;l|Fm|xCvo6?l;Y5u`3yLV>B9h$zSkZ*Fch8VwNdDATw1>qAnMx$KtVukvzhBobz$tkdN}`jA714<>&q z2m-{CC_}!KX|G0OX=i6gep&H6%qXEU8n53UfRz>8+NT)XmohMx3CfTU$MNn@dR(p< z8I)xW8Ejuk6q}toqoe)GTH5^f&2BT*jbT0kcLYJ`R)#=>m%4Ica^}M)x-KQ3*|T?bv>alXsj2AmO7mKig$Qn;G6k3gcFboq2WU~4=h9KF758~-4N@Xw@ zBawxrrKKz{+yFAjM-UXprA~O}`B;8|;=)P9X~<=A9`urAhL!}24DzYf8iJsb&j=6oCZcs>@3$!HLh2cgiy!oq5AX0`6b)f*z8C<-hK zIY74YU>KI%d!Q)PnE~s3#X!0x zToi6_A`fVqe)Jnh34(x|EkWQGi`il^4-5=BrweifGuLE6;M0eG8&AtU`=D!=Ok-oA zQ#&%tCx>Rc?5sf&)vA0v zALG)(dGdrLiJXRk;e|*zEXigKU&|-wW}8mi{r8#8xy{pm{k1MBO7iupPf zE+7hW`KECt#s&KO`D?qFf*_FGWd|Ctpeky0f!``oTwfzFPUcKvDZB`IbYDX;M|3^Osl zW}3Xm?Fk0g>@@VnfBTaUS(f$r)=YJVh4N}2hG9KD7tWnK|Ep(CA_&seb;jfNXtny} z(aH3*^Y4FqF>we0C@Ika0Kcv%()MY!I;~bWJUn7DnPQxf9>`Lwi{h)cq(+hQ#p&(2 z005wAjTF@?OB5`hR;v>PVH34e{JM_K)lS*9ZyjoCst+D&O?;j{vVnyn<*TSDuWzXJ zxV_sseWb;a!qEr;6ivOR{Ig8uA#NmqVlO$d*t$cZ` z932`Q9vr+XS&1OCz4>NAz?Uy&6MBd^LFSLZRC+!moV*0Ie3z z+iQwaO-@WsPE49iCJL(C;Bk8az%UFer0U7t>nJWR;W#c9<8oa=zCQReBmkhaw9rlJ zWGx>6z-TlrE=E{(3D%@*05FPDk)%qaDK6Z?D8T21_}Xk`-$WYXs=zzT>mW%L+!d8~ zkbF2!B(IW(!kr=?02oQC;AaaxM;U^mC`GCA+w*vR?g;q^f|6)Qimfjnj^kNwp^^t% zTs{B*NlLf{_7<0qqSR8Dyg17^32tuN(ICkujmX>{}D2k!b!gJ5P(9`qj s_V4507LA5(X=!e6KXUBYqX>fhANw<28gaN%r~m)}07*qoM6N<$g2c`Ic>n+a literal 0 HcmV?d00001 diff --git a/l10n_mx_cfdi/static/description/icon.svg b/l10n_mx_cfdi/static/description/icon.svg new file mode 100644 index 0000000..c868bbe --- /dev/null +++ b/l10n_mx_cfdi/static/description/icon.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/l10n_mx_cfdi/static/description/index.html b/l10n_mx_cfdi/static/description/index.html new file mode 100644 index 0000000..5500d01 --- /dev/null +++ b/l10n_mx_cfdi/static/description/index.html @@ -0,0 +1,460 @@ + + + + + +README.rst + + + +
+ + +
+

Mexico - Electronic Invoicing

+ +

Beta License: LGPL-3 OCA/l10n-mexico Translate me on Weblate Try me on Runboat

+
+
+

l10n_mx_cfdi

+

This module provides electronic invoicing for Mexico (CFDI 4.0) using Facturama as the only PAC (for now)

+
+

Features

+
    +
  • Generation of electronic invoices compliant with the CFDI 4.0 standard.
  • +
  • Integration with Facturama (for now) as the PAC for the issuance and stamping of invoices.
  • +
  • Customization of fiscal documents according to user needs.
  • +
  • Centralized management of electronic invoices within Odoo.
  • +
  • Tracking and recording of issued and received fiscal documents.
  • +
+
+
+

System Requirements

+
    +
  • Odoo 15.0
  • +
  • Active account in Facturama
  • +
  • Pre-configuration of fiscal and company data in Odoo.
  • +
+
+
+

Installation

+
    +
  1. Log in to Odoo as an administrator and navigate to the applications section.
  2. +
  3. Search for “l10n_mx_cfdi” and click install.
  4. +
  5. Configure module settings by entering access credentials for Facturama and other required details.
  6. +
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+
+

l10n_mx_cfdi

+

This module provides electronic invoicing for Mexico (CFDI 4.0) using Facturama as the only PAC (for now)

+
+

Usage

+
+

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

+ +
+
+

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-mexico project on GitHub.

+

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

+
+
+
+
+
+ + diff --git a/l10n_mx_cfdi/tests/__init__.py b/l10n_mx_cfdi/tests/__init__.py new file mode 100644 index 0000000..841dc09 --- /dev/null +++ b/l10n_mx_cfdi/tests/__init__.py @@ -0,0 +1,13 @@ +# from . import test_account_move +from . import test_account_move_line + +# from . import test_account_payment +from . import test_account_tax + +# from . import test_cfdi_document +from . import test_cfdi_issuer +from . import test_cfdi_series + +# from . import test_cfdi_service +from . import test_cfdi_service_topup +from . import test_res_company diff --git a/l10n_mx_cfdi/tests/test_account_move.py b/l10n_mx_cfdi/tests/test_account_move.py new file mode 100644 index 0000000..cbbd3ff --- /dev/null +++ b/l10n_mx_cfdi/tests/test_account_move.py @@ -0,0 +1,10 @@ +# Copyright (C) 2019 Brian McMaster +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestAccountMove(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() diff --git a/l10n_mx_cfdi/tests/test_account_move_line.py b/l10n_mx_cfdi/tests/test_account_move_line.py new file mode 100644 index 0000000..8ad4e76 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_account_move_line.py @@ -0,0 +1,104 @@ +from odoo.tests import TransactionCase + + +class TestAccountMoveLine(TransactionCase): + def setUp(self): + super().setUp() + + self.company = self.env["res.company"].create( + { + "name": "Test Company", + # Add other required fields here + } + ) + + self.currency = self.env["res.currency"].create( + { + "name": "Test Currency", + "symbol": "TC", + "decimal_places": 2, + # Add other required fields here + } + ) + + self.tax = self.env["account.tax"].create( + { + "name": "IVA", + "amount": 10, + "amount_type": "percent", + "type_tax_use": "sale", + "country_id": self.env.ref("base.mx").id, + # Add other required fields here + } + ) + + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "default_code": "TP", + # Add other required fields here + } + ) + + journal_id = ( + self.env["account.journal"].search([("type", "=", "sale")], limit=1).id + ) + + self.move_line_1_vals = { + "name": "Test Move Line 1", + "product_id": self.product.id, + "price_unit": 100, + "quantity": 2, + "price_subtotal": 200.00, + "price_total": 220.00, + # 'tax_ids': [(6, 0, [self.tax.id])], + "debit": 200, + "account_id": self.env.ref( + "account.data_account_type_revenue" + ).id, # Example revenue account + } + + self.move_line_2_vals = { + "name": "Test Move Line 2", + "product_id": self.product.id, + "price_unit": 200, + "quantity": 1, + "price_subtotal": 200.00, + "price_total": 220.00, + # 'tax_ids': [(6, 0, [self.tax.id])], + "credit": 200, + "account_id": self.env.ref( + "account.data_account_type_current_assets" + ).id, # Example asset account + } + + self.move_vals = { + "name": "Test Move", + "move_type": "out_invoice", + "currency_id": self.currency.id, + "company_id": self.company.id, + "journal_id": journal_id, + "line_ids": [(0, 0, self.move_line_1_vals), (0, 0, self.move_line_2_vals)], + } + + def test_compute_cfdi_fields(self): + move = self.env["account.move"].create(self.move_vals) + + move_line_1 = move.line_ids.filtered( + lambda line: line.name == "Test Move Line 1" + ) + move_line_2 = move.line_ids.filtered( + lambda line: line.name == "Test Move Line 2" + ) + + move_line_1._compute_cfdi_fields() + move_line_2._compute_cfdi_fields() + + # Assuming the test logic for computing CFDI fields here... + + self.assertEqual(move_line_1.cfdi_subtotal, -200.00) + self.assertEqual(move_line_1.cfdi_price_unit, -100.00) + self.assertEqual(move_line_2.cfdi_subtotal, 200.00) + self.assertEqual(move_line_2.cfdi_price_unit, 200.00) + + # Add assertions for move_line_2 if necessary diff --git a/l10n_mx_cfdi/tests/test_account_payment.py b/l10n_mx_cfdi/tests/test_account_payment.py new file mode 100644 index 0000000..6a57805 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_account_payment.py @@ -0,0 +1,142 @@ +# from odoo.exceptions import ValidationError +# from odoo.tests import TransactionCase +# +# +# class TestAccountPayment(TransactionCase): +# +# def setUp(self): +# super().setUp() +# +# # Create test data +# self.partner = self.env["res.partner"].create( +# { +# "name": "Test Partner", +# "vat": "TESTVAT", +# "zip": "12345", # Add other required fields +# } +# ) +# +# self.payment_journal = self.env["account.journal"].create( +# { +# "name": "Test Journal", +# "code": "TEST", +# "type": "sale", +# } +# ) +# +# self.payment_method = self.env["account.payment.method"].create( +# { +# "name": "Test Payment Method", +# "code": "TEST_PM", +# "payment_type": "inbound", # Adjust payment type as needed +# } +# ) +# +# self.payment_method_line = self.env["account.payment.method.line"].create( +# { +# "payment_method_id": self.payment_method.id, +# "payment_type": "inbound", # Adjust payment type as needed +# "journal_id": self.payment_journal.id, +# "sequence": 1, +# # Add other required fields +# } +# ) +# +# self.payment = self.env["account.payment"].create( +# { +# "partner_id": self.partner.id, +# "journal_id": self.payment_journal.id, +# "amount": 100.0, +# "payment_type": "inbound", +# "payment_method_line_id": self.payment_method_line.id, +# } +# ) +# +# self.issuer = self.env["res.partner"].create( +# { +# "name": "Issuer", +# "vat": "ISSUERVAT", +# "zip": "54321", # Add other required fields +# } +# ) +# +# self.invoice = self.env["account.move"].create( +# { +# "partner_id": self.partner.id, +# "type": "out_invoice", # Example type, adjust as needed +# "issuer_id": self.issuer.id, +# "amount_total": 50.0, # Example amount, adjust as needed +# # Add other required fields +# } +# ) +# +# self.payment.reconciled_invoice_ids = [(4, self.invoice.id)] +# +# def test_action_generate_cfdi_with_existing_cfdi(self): +# # Add a related CFDI to the payment +# self.payment.write( +# {"cfdi_document_id": self.env["l10n_mx_cfdi.document"].create({}).id} +# ) +# +# # Try to generate CFDI again, it should raise validation error +# with self.assertRaises(ValidationError): +# self.payment.action_generate_cfdi() +# +# def test_action_generate_cfdi_not_fully_reconciled(self): +# # Try to generate CFDI for a payment that is not fully reconciled +# with self.assertRaises(ValidationError): +# self.payment.action_generate_cfdi() +# +# def test_create_payment_cfdi(self): +# # Create a fully reconciled payment +# self.payment.move_type = "entry" +# self.payment.is_reconciled = True +# self.payment.create_payment_cfdi() +# +# # Check if the payment has a related CFDI +# self.assertTrue(self.payment.cfdi_document_id) +# self.assertEqual(self.payment.cfdi_document_id.type, "P") +# self.assertEqual(self.payment.cfdi_document_id.issuer_id.zip, "12345") +# self.assertEqual(self.payment.cfdi_document_id.receiver_id.vat, "TESTVAT") +# +# def test_create_payment_cfdi_with_legacy_invoice(self): +# # Create a fully reconciled payment with a legacy invoice +# self.payment.move_type = "entry" +# self.payment.is_reconciled = True +# self.payment.reconciled_invoice_ids = [ +# ( +# 0, +# 0, +# { +# "related_cert_ids": [ +# ( +# 0, +# 0, +# { +# "type": "I", +# "state": "published", +# "receiver_id": self.partner.id, +# }, +# ) +# ] +# }, +# ) +# ] +# +# # Now, receiver should be resolved from invoice CFDI +# self.payment.create_payment_cfdi() +# +# # Check if the payment has a related CFDI +# self.assertTrue(self.payment.cfdi_document_id) +# +# def test_cancel_payment_cfdi(self): +# # Create a payment with related CFDI +# self.payment.move_type = "entry" +# self.payment.is_reconciled = True +# self.payment.create_payment_cfdi() +# +# # Cancel the payment CFDI +# self.payment.cancel_payment_cfdi() +# +# # Check if the CFDI is canceled +# self.assertEqual(self.payment.cfdi_document_id.state, "canceled") diff --git a/l10n_mx_cfdi/tests/test_account_tax.py b/l10n_mx_cfdi/tests/test_account_tax.py new file mode 100644 index 0000000..632b7b1 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_account_tax.py @@ -0,0 +1,44 @@ +from odoo.exceptions import UserError +from odoo.tests import TransactionCase + + +class TestAccountTax(TransactionCase): + def setUp(self): + super().setUp() + self.tax_isr = self.env["account.tax"].create( + { + "name": "ISR Tax", + # Add other required fields here + } + ) + self.tax_iva = self.env["account.tax"].create( + { + "name": "IVA Tax", + # Add other required fields here + } + ) + self.tax_ieps = self.env["account.tax"].create( + { + "name": "IEPS Tax", + # Add other required fields here + } + ) + + def test_extract_l10n_mx_tax_code(self): + self.assertEqual(self.tax_isr.extract_l10n_mx_tax_code(), "ISR") + self.assertEqual(self.tax_iva.extract_l10n_mx_tax_code(), "IVA") + self.assertEqual(self.tax_ieps.extract_l10n_mx_tax_code(), "IEPS") + with self.assertRaises(UserError): + self.env["account.tax"].create( + { + "name": "Test Tax", + # Add other required fields here + } + ).extract_l10n_mx_tax_code() + + def test_extract_is_retention(self): + self.assertFalse(self.tax_isr.extract_is_retention()) + self.assertFalse(self.tax_iva.extract_is_retention()) + self.assertFalse(self.tax_ieps.extract_is_retention()) + tax_retention = self.env.ref("l10n_mx.1_tax2") + self.assertTrue(tax_retention.extract_is_retention()) diff --git a/l10n_mx_cfdi/tests/test_cfdi_document.py b/l10n_mx_cfdi/tests/test_cfdi_document.py new file mode 100644 index 0000000..c4e7d37 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_cfdi_document.py @@ -0,0 +1,134 @@ +from base64 import b64encode + +from odoo.tests.common import TransactionCase + + +class TestCFDIDocument(TransactionCase): + def setUp(self): + super().setUp() + + self.service = self.env["l10n_mx_cfdi.cfdi_service"].create( + { + "name": "Test service", + "user": "Test user", + "password": "12345", + } + ) + + self.issuer = self.env["l10n_mx_cfdi.issuer"].create( + { + "name": "Test Issuer", + "vat": "RFC123456", + "certificate_file": b64encode(b"certificate"), + "key_file": b64encode(b"key"), + "key_password": "password", + "service_id": self.service.id, + } + ) + + self.partner = self.env["res.partner"].create( + { + "name": "Test Partner", + "vat": "TESTVAT", + "zip": "12345", + } + ) + + def test_create_document(self): + vals = { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + document = self.env["l10n_mx_cfdi.document"].create(vals) + self.assertEqual(document.state, "draft") + + def test_cancel_document(self): + document = self.env["l10n_mx_cfdi.document"].create( + { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + ) + document.publish(cfdi_data={}) # Publish document + document.cancel(reason="Test Reason") + self.assertEqual(document.state, "canceled") + self.assertFalse(document.pdf_file) + self.assertFalse(document.xml_file) + + def test_publish_document(self): + document = self.env["l10n_mx_cfdi.document"].create( + { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + ) + document.publish(cfdi_data={}) + self.assertEqual(document.state, "published") + self.assertTrue(document.tracking_id) + + def test_action_cancel(self): + document = self.env["l10n_mx_cfdi.document"].create( + { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + ) + action = document.action_cancel() + self.assertEqual(action["res_model"], "l10n_mx_cfdi.document_cancel") + + def test_action_check_status(self): + document = self.env["l10n_mx_cfdi.document"].create( + { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + ) + document.publish(cfdi_data={}) + document.action_check_status() + self.assertNotEqual(document.state, "draft") + + def test_action_get_cancellation_request_proof(self): + document = self.env["l10n_mx_cfdi.document"].create( + { + "issuer_id": self.issuer.id, + "receiver_id": self.partner.id, + "type": "I", # Example type, adjust as needed + # Add other required fields here + } + ) + document.publish(cfdi_data={}) + document.cancel(reason="Test Reason") + document.action_get_cancellation_request_proof() + self.assertTrue(document.cancellation_request_proof_file) + + +# class TestCFDIDocumentRelation(TransactionCase): +# +# def test_document_relation_creation(self): +# relation_type = self.env['l10n_mx_catalogs.c_tipo_relacion'].create({ +# 'name': 'Test Relation Type', +# # Add other required fields here +# }) +# document_1 = self.env['l10n_mx_cfdi.document'].create({ +# # Create document 1 fields +# }) +# document_2 = self.env['l10n_mx_cfdi.document'].create({ +# # Create document 2 fields +# }) +# relation = self.env['l10n_mx_cfdi.document_relation'].create({ +# 'relation_type_id': relation_type.id, +# 'source_id': document_1.id, +# 'target_id': document_2.id, +# }) +# self.assertTrue(relation) +# diff --git a/l10n_mx_cfdi/tests/test_cfdi_issuer.py b/l10n_mx_cfdi/tests/test_cfdi_issuer.py new file mode 100644 index 0000000..d75808f --- /dev/null +++ b/l10n_mx_cfdi/tests/test_cfdi_issuer.py @@ -0,0 +1,53 @@ +from base64 import b64encode + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestCFDIIssuer(TransactionCase): + def setUp(self): + super().setUp() + + self.service = self.env["l10n_mx_cfdi.cfdi_service"].create( + { + "name": "Test service", + "user": "Test user", + "password": "12345", + } + ) + + self.issuer = self.env["l10n_mx_cfdi.issuer"].create( + { + "name": "Test Issuer", + "vat": "RFC123456", + "certificate_file": b64encode(b"certificate"), + "key_file": b64encode(b"key"), + "key_password": "password", + "service_id": self.service.id, + } + ) + + def test_default_get_method(self): + # Test default_get method + issuer = self.env["l10n_mx_cfdi.issuer"].default_get([]) + self.assertEqual(issuer["country_id"], self.env.ref("base.mx").id) + + def test_slugify_method(self): + # Test _slugify method + issuer = self.env["l10n_mx_cfdi.issuer"] + self.assertEqual(issuer._slugify("Test String"), "test_string") + + def test_create_default_cfdi_sequence_method(self): + # Test _create_default_cfdi_sequence method + sequence = self.issuer._create_default_cfdi_sequence("Test") + self.assertEqual(sequence.name, "Folios CFDI Test") + + def test_register_issuer(self): + # Test register_issuer method + with self.assertRaises(UserError): + self.issuer.register_issuer() + + def test_unregister_issuer(self): + # Test unregister_issuer method + with self.assertRaises(UserError): + self.issuer.unregister_issuer() diff --git a/l10n_mx_cfdi/tests/test_cfdi_series.py b/l10n_mx_cfdi/tests/test_cfdi_series.py new file mode 100644 index 0000000..e26d597 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_cfdi_series.py @@ -0,0 +1,21 @@ +from odoo.tests.common import TransactionCase + + +class TestCFDISeries(TransactionCase): + def test_create_method(self): + # Create a CFDISeries record without implementation provided + series = self.env["l10n_mx_cfdi.series"].create( + {"name": "Test Series", "code": "TEST"} + ) + + # Check if the implementation is set to "no_gap" + self.assertEqual(series.implementation, "no_gap") + + def test_create_method_with_implementation(self): + # Create a CFDISeries record with implementation provided + series = self.env["l10n_mx_cfdi.series"].create( + {"name": "Test Series", "code": "TEST", "implementation": "standard"} + ) + + # Check if the implementation is set to the provided value + self.assertEqual(series.implementation, "standard") diff --git a/l10n_mx_cfdi/tests/test_cfdi_service.py b/l10n_mx_cfdi/tests/test_cfdi_service.py new file mode 100644 index 0000000..a6a1be7 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_cfdi_service.py @@ -0,0 +1,62 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestCFDIService(TransactionCase): + def setUp(self): + super().setUp() + self.cfdi_service = self.env["l10n_mx_cfdi.cfdi_service"].create( + { + "name": "Test Service", + "user": "test_user", + "password": "test_password", + "sandbox_mode": True, + } + ) + + def test_register_csd(self): + # Test registering CSD + with self.assertRaises(UserError): + self.cfdi_service.register_csd("RFC", b"cert", b"key", "password") + + def test_unregister_csd(self): + # Test unregistering CSD + with self.assertRaises(UserError): + self.cfdi_service.unregister_csd("RFC") + + def test_get_csd_status(self): + # Test getting CSD status + status = self.cfdi_service.get_csd_status("RFC") + self.assertEqual(status, {}) + + def test_create_cfdi(self): + # Test creating CFDI + with self.assertRaises(UserError): + self.cfdi_service.create_cfdi({}) + + def test_cancel_cfdi(self): + # Test cancelling CFDI + with self.assertRaises(UserError): + self.cfdi_service.cancel_cfdi("cfdi_id", "reason", "uuid_replacement") + + def test_get_cancellation_request_proof(self): + # Test getting cancellation request proof + with self.assertRaises(UserError): + self.cfdi_service.get_cancellation_request_proof("cfdi_id") + + def test_check_cfdi_status(self): + # Test checking CFDI status + status = self.cfdi_service.check_cfdi_status( + "uudi", "issuer_rfc", "receiver_rfc", "amount_total" + ) + self.assertEqual(status, "unknown") + + def test_get_cfdi_pdf(self): + # Test getting CFDI PDF + pdf_content = self.cfdi_service.get_cfdi_pdf("cfdi_id") + self.assertTrue(isinstance(pdf_content, bytes)) + + def test_get_cfdi_xml(self): + # Test getting CFDI XML + xml_content = self.cfdi_service.get_cfdi_xml("cfdi_id") + self.assertTrue(isinstance(xml_content, bytes)) diff --git a/l10n_mx_cfdi/tests/test_cfdi_service_topup.py b/l10n_mx_cfdi/tests/test_cfdi_service_topup.py new file mode 100644 index 0000000..7b57f45 --- /dev/null +++ b/l10n_mx_cfdi/tests/test_cfdi_service_topup.py @@ -0,0 +1,29 @@ +from odoo.tests.common import TransactionCase + + +class TestCFDIServiceTopUp(TransactionCase): + def setUp(self): + super().setUp() + + # Create a test CFDI Service TopUp + self.cfdi_service_topup = self.env["l10n_mx_cfdi.cfdi_service.topup"].create( + { + "stamp_number": 10, + "stamp_price": 5.0, + "service_id": self.env["l10n_mx_cfdi.cfdi_service"] + .create( + { + "name": "Test CFDI Service", + "user": "test_user", + "password": "test_password", + } + ) + .id, + # Add other required fields + } + ) + + def test_compute_total(self): + self.assertEqual(self.cfdi_service_topup.total, 50.0) + + # Add more test methods to cover other functionalities and scenarios... diff --git a/l10n_mx_cfdi/tests/test_res_company.py b/l10n_mx_cfdi/tests/test_res_company.py new file mode 100644 index 0000000..065e72f --- /dev/null +++ b/l10n_mx_cfdi/tests/test_res_company.py @@ -0,0 +1,41 @@ +from odoo.tests import TransactionCase + + +class TestResCompany(TransactionCase): + def test_l10n_mx_cfdi_auto_default(self): + company = self.env["res.company"].create( + { + "name": "Test Company", + # Add other required fields here + } + ) + self.assertTrue(company.l10n_mx_cfdi_auto) + + def test_l10n_mx_cfdi_enabled(self): + company_mx = self.env["res.company"].create( + { + "name": "Test Company MX", + "country_id": self.env.ref("base.mx").id, + # Add other required fields here + } + ) + company_other = self.env["res.company"].create( + { + "name": "Test Company Other", + "country_id": self.env.ref("base.us").id, + # Add other required fields here + } + ) + self.assertTrue(company_mx.l10n_mx_cfdi_enabled) + self.assertFalse(company_other.l10n_mx_cfdi_enabled) + + def test_l10n_mx_cfdi_enabled_change_country(self): + company = self.env["res.company"].create( + { + "name": "Test Company", + "country_id": self.env.ref("base.us").id, + # Add other required fields here + } + ) + company.country_id = self.env.ref("base.mx").id + self.assertTrue(company.l10n_mx_cfdi_enabled) diff --git a/l10n_mx_cfdi/views/account_move.xml b/l10n_mx_cfdi/views/account_move.xml new file mode 100644 index 0000000..09213a9 --- /dev/null +++ b/l10n_mx_cfdi/views/account_move.xml @@ -0,0 +1,36 @@ + + + + account.out.invoice.tree.cfdi + account.move + + + + + + + + + + + account.invoice.filter.cfdi + account.move + + + + + + + + + diff --git a/l10n_mx_cfdi/views/account_payment.xml b/l10n_mx_cfdi/views/account_payment.xml new file mode 100644 index 0000000..bef3935 --- /dev/null +++ b/l10n_mx_cfdi/views/account_payment.xml @@ -0,0 +1,47 @@ + + + Account Payment: Payment Form + account.payment + + + + + + + + + + + + + diff --git a/l10n_mx_cfdi/views/account_payment_register.xml b/l10n_mx_cfdi/views/account_payment_register.xml new file mode 100644 index 0000000..187c307 --- /dev/null +++ b/l10n_mx_cfdi/views/account_payment_register.xml @@ -0,0 +1,12 @@ + + + Account Move Payment: CFDI generation + account.payment.register + + + + + + + + diff --git a/l10n_mx_cfdi/views/cfdi_document.xml b/l10n_mx_cfdi/views/cfdi_document.xml new file mode 100644 index 0000000..757a055 --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_document.xml @@ -0,0 +1,163 @@ + + + + Documentos CFDI + l10n_mx_cfdi.document + + + + + + + + + + + + + + + + + + + + + + + + Documentos CFDI + l10n_mx_cfdi.document + + + + + + + + + + + + + + + + + + Documentos CFDI + l10n_mx_cfdi.document + +
+ + + + + + + + + + + + + + + + + +
+ + +
+
+
Documentos Relacionados
+ + + + + + +
+
+ + +
+
+
+
+
+ +
diff --git a/l10n_mx_cfdi/views/cfdi_documents_issued.xml b/l10n_mx_cfdi/views/cfdi_documents_issued.xml new file mode 100644 index 0000000..86993dc --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_documents_issued.xml @@ -0,0 +1,19 @@ + + + + CFDI Emitidos + l10n_mx_cfdi.document + tree,form + [('type','in',['I', 'E', 'P'])] + {'search_default_filter_published': 1} + + + + + diff --git a/l10n_mx_cfdi/views/cfdi_issuer.xml b/l10n_mx_cfdi/views/cfdi_issuer.xml new file mode 100644 index 0000000..c64b536 --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_issuer.xml @@ -0,0 +1,113 @@ + + + + Emisor de CFDIs + l10n_mx_cfdi.issuer + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + Emisores + l10n_mx_cfdi.issuer + tree,form + + + + +
diff --git a/l10n_mx_cfdi/views/cfdi_menu.xml b/l10n_mx_cfdi/views/cfdi_menu.xml new file mode 100644 index 0000000..4125862 --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_menu.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/l10n_mx_cfdi/views/cfdi_series.xml b/l10n_mx_cfdi/views/cfdi_series.xml new file mode 100644 index 0000000..ae77f0b --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_series.xml @@ -0,0 +1,50 @@ + + + + Serie CFDI + l10n_mx_cfdi.series + +
+ + + + + + + + + +
+
+
+ + + Serie CFDI + l10n_mx_cfdi.series + + + + + + + + + + + + Serie CFDI + l10n_mx_cfdi.series + tree,form + + + + + + +
diff --git a/l10n_mx_cfdi/views/cfdi_service.xml b/l10n_mx_cfdi/views/cfdi_service.xml new file mode 100644 index 0000000..81d3bc1 --- /dev/null +++ b/l10n_mx_cfdi/views/cfdi_service.xml @@ -0,0 +1,57 @@ + + + + Configuración del Servicio CFDI + l10n_mx_cfdi.cfdi_service + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Configuración de Servicio + l10n_mx_cfdi.cfdi_service + tree,form + + + + +
diff --git a/l10n_mx_cfdi/views/product_template.xml b/l10n_mx_cfdi/views/product_template.xml new file mode 100644 index 0000000..619847f --- /dev/null +++ b/l10n_mx_cfdi/views/product_template.xml @@ -0,0 +1,17 @@ + + + Product Template: CFDI code and measurement unit fields + product.template + + + + + + + + + + + diff --git a/l10n_mx_cfdi/views/res_config_settings.xml b/l10n_mx_cfdi/views/res_config_settings.xml new file mode 100644 index 0000000..f2616b1 --- /dev/null +++ b/l10n_mx_cfdi/views/res_config_settings.xml @@ -0,0 +1,30 @@ + + + res.config.settings.view.form.inherit.l10n.mx + res.config.settings + + + + 1 + + +
+
+ + +
+
+
+
+
+
+
+
diff --git a/l10n_mx_cfdi/views/res_partner.xml b/l10n_mx_cfdi/views/res_partner.xml new file mode 100644 index 0000000..fdf2c7c --- /dev/null +++ b/l10n_mx_cfdi/views/res_partner.xml @@ -0,0 +1,18 @@ + + + Partner: tax regime field + res.partner + + + + 1 + + + + + + + + + + diff --git a/l10n_mx_cfdi/wizards/__init__.py b/l10n_mx_cfdi/wizards/__init__.py new file mode 100644 index 0000000..a5d4f04 --- /dev/null +++ b/l10n_mx_cfdi/wizards/__init__.py @@ -0,0 +1,4 @@ +from . import account_payment_register +from . import document_cancel +from . import create_cfdi_publico_en_general +from . import download_cfdi_files diff --git a/l10n_mx_cfdi/wizards/account_invoice_send_views.xml b/l10n_mx_cfdi/wizards/account_invoice_send_views.xml new file mode 100644 index 0000000..b40ed44 --- /dev/null +++ b/l10n_mx_cfdi/wizards/account_invoice_send_views.xml @@ -0,0 +1,15 @@ + + + account.invoice.send.no_print + account.invoice.send + + +
+ 1 +
+
+ 1 +
+
+
+
diff --git a/l10n_mx_cfdi/wizards/account_payment_register.py b/l10n_mx_cfdi/wizards/account_payment_register.py new file mode 100644 index 0000000..584b61c --- /dev/null +++ b/l10n_mx_cfdi/wizards/account_payment_register.py @@ -0,0 +1,39 @@ +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class AccountPaymentRegister(models.TransientModel): + _inherit = "account.payment.register" + + payment_form_id = fields.Many2one( + "l10n_mx_catalogs.c_forma_pago", string="Forma de Pago", required=True + ) + + def _init_payments(self, to_process, edit_mode=False): + """ + Add payment for id to payments creation data + """ + for entry in to_process: + entry["create_vals"].update( + { + "payment_form_id": self.payment_form_id.id, + } + ) + + return super()._init_payments(to_process, edit_mode) + + def _create_payments(self): + # Prevent partial payments on invoices with cfdi and payment method different of 'PPD' + if self.payment_difference > 0: + related_invoices = self.line_ids.move_id + if any( + invoice.cfdi_required and invoice.payment_method_id.code != "PPD" + for invoice in related_invoices + ): + raise UserError( + _( + "You cannot register a partial payment against an " + "invoice with a CFDI and PUE as the payment method." + ) + ) + return super()._create_payments() diff --git a/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.py b/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.py new file mode 100644 index 0000000..8fc556e --- /dev/null +++ b/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.py @@ -0,0 +1,166 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import datetime + + +class CFDIGenericInvoiceCreate(models.TransientModel): + _name = "l10n_mx_cfdi.generic_invoice_create" + _description = "Create a generic CFDI invoice" + + periodicity_id = fields.Many2one( + "l10n_mx_catalogs.c_periodicidad", string="Periodicity", required=True + ) + + meses_id = fields.Many2one("l10n_mx_catalogs.c_meses", string="Mes", required=True) + + year = fields.Text(string="Año", required=True) + + issuer_id = fields.Many2one( + "l10n_mx_cfdi.issuer", + string="Emisor", + domain=[("registered", "=", True)], + required=True, + ) + + payment_method_id = fields.Many2one( + "l10n_mx_catalogs.c_metodo_pago", string="Metodo de Pago", readonly=True + ) + payment_form_id = fields.Many2one( + "l10n_mx_catalogs.c_forma_pago", string="Forma de Pago" + ) + + fiscal_regime_id = fields.Many2one( + "l10n_mx_catalogs.c_regimen_fiscal", + string="Régimen Fiscal", + required=True, + compute="_compute_fiscal_regime_id", + readonly=True, + ) + + cfdi_use_id = fields.Many2one( + "l10n_mx_catalogs.c_uso_cfdi", + string="Uso de CFDI", + required=True, + readonly=True, + ) + move_ids = fields.Many2many("account.move", string="Facturas", required=True) + + date = fields.Date(string="Fecha", required=True, default=fields.Date.context_today) + + @api.depends("periodicity_id") + def _compute_fiscal_regime_id(self): + for record in self: + if record.periodicity_id.code == "05": + record.fiscal_regime_id = self.env.ref( + "l10n_mx_catalogs.c_regimen_fiscal_621" + ) + else: + record.fiscal_regime_id = self.env.ref( + "l10n_mx_catalogs.c_regimen_fiscal_616" + ) + + @api.model + def default_get(self, field_names): + defaults_dict = super().default_get(field_names) + context = self.env.context + + if context["active_model"] == "account.move": + related_invoice_objs = self.env["account.move"].browse( + context["active_ids"] + ) + for invoice in related_invoice_objs: + self._validate_invoice(invoice) + + defaults_dict.update({"move_ids": related_invoice_objs}) + + currentDateTime = datetime.datetime.now() + defaults_dict.update( + { + "year": currentDateTime.strftime("%Y"), + "cfdi_use_id": self.env.ref("l10n_mx_catalogs.c_uso_cfdi_S01").id, + "payment_method_id": self.env.ref( + "l10n_mx_catalogs.c_metodo_pago_PUE" + ).id, + "payment_form_id": self.env.ref("l10n_mx_catalogs.c_forma_pago_01").id, + } + ) + return defaults_dict + + @api.constrains("move_ids") + def _validate_included_invoices(self): + for record in self: + for invoice in record.move_ids: + self._validate_invoice(invoice) + + @api.model + def _validate_invoice(self, invoice): + invoice.ensure_one() + + if invoice.state != "posted": + raise ValidationError(_("Invoice %s is not posted.") % invoice.name) + + related_cfdi = invoice.related_cert_ids.filtered_domain( + [("state", "=", "published")] + ) + if related_cfdi: + raise ValidationError( + _("Invoice %s already has a published CFDI.") % invoice.name + ) + + err_msg = invoice.validate_invoice_items_for_cfdi_generation() + if err_msg: + raise ValidationError(err_msg) + + def create_cfdi(self): + """Emit CFDI 'Al Público en General'""" + + self.ensure_one() + receiver = self.env.ref( + "l10n_mx_cfdi.l10n_mx_cfdi_res_partner_publico_en_general" + ) + cert = self.env["l10n_mx_cfdi.document"].create( + { + "type": "I", + "issuer_id": self.issuer_id.id, + "receiver_id": receiver.id, + "is_global_note": True, + } + ) + + try: + all_items_data = [] + for invoice in self.move_ids: + items_data = invoice.gather_invoice_cfdi_items_data() + all_items_data.extend(items_data) + currency = self.move_ids[0].currency_id + + cfdi_data = { + "Currency": currency[0].name, + "ExpeditionPlace": self.issuer_id.zip, + "CfdiType": "I", + "Date": self.move_ids._format_cfdi_date_str(self.date), + "PaymentForm": self.payment_form_id.code, + "PaymentMethod": self.payment_method_id.code, + "GlobalInformation": { + "Periodicity": self.periodicity_id.code, + "Months": self.meses_id.code, + "Year": self.year, + }, + "Receiver": { + "Name": receiver.name, + "Rfc": receiver.vat, + "CfdiUse": self.cfdi_use_id.code, + "FiscalRegime": receiver.tax_regime.code, + "TaxZipCode": self.issuer_id.zip, + }, + "Items": all_items_data, + } + + cert.publish(cfdi_data) + + for invoice in self.move_ids: + invoice.related_cert_ids = [(4, cert.id)] + + except Exception as e: + cert.unlink() + raise e diff --git a/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.xml b/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.xml new file mode 100644 index 0000000..e3fe395 --- /dev/null +++ b/l10n_mx_cfdi/wizards/create_cfdi_publico_en_general.xml @@ -0,0 +1,64 @@ + + + Crear CFDI al Público en General + l10n_mx_cfdi.generic_invoice_create + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Crear CFDI al Público en General + l10n_mx_cfdi.generic_invoice_create + form + new + + list + +
diff --git a/l10n_mx_cfdi/wizards/document_cancel.py b/l10n_mx_cfdi/wizards/document_cancel.py new file mode 100644 index 0000000..acaf737 --- /dev/null +++ b/l10n_mx_cfdi/wizards/document_cancel.py @@ -0,0 +1,75 @@ +from odoo import api, fields, models + + +class CertificateCancel(models.TransientModel): + _name = "l10n_mx_cfdi.document_cancel" + _description = "Certificate Cancel" + + certificate_ids = fields.Many2many( + "l10n_mx_cfdi.document", string="Certificados", required=True + ) + cancel_reason_id = fields.Many2one( + "l10n_mx_catalogs.c_motivo_cancelacion", string="Razón", required=True + ) + replacement_certificate_id = fields.Many2one( + "l10n_mx_cfdi.document", string="Reemplazo" + ) + single_cancel = fields.Boolean(default=True) + + related_invoices = fields.Many2many("account.move", string="Facturas Relacionadas") + + requires_replacement = fields.Boolean( + compute="_compute_requires_replacement", store=False + ) + simulate_operation = fields.Boolean( + default=False, + help="Simulate the cancel operation without sending the request to the SAT", + groups="base.group_system", + ) + + @api.depends("cancel_reason_id") + def _compute_requires_replacement(self): + for record in self: + record.requires_replacement = record.cancel_reason_id.code == "01" + + @api.model + def default_get(self, field_names): + defaults_dict = super().default_get(field_names) + context = self.env.context + + if context["active_model"] == "account.move": + related_invoice_objs = self.env["account.move"].browse( + context["active_ids"] + ) + defaults_dict.update( + { + "certificate_ids": related_invoice_objs.related_cert_ids.filtered_domain( + [("state", "=", "published")] + ), + "related_invoices": related_invoice_objs, + "cancel_reason_id": self.env.ref( + "l10n_mx_catalogs.c_motivo_cancelacion_02" + ).id, + } + ) + + return defaults_dict + + def cancel_certificate(self): + for record in self: + for certificate in record.certificate_ids: + if certificate.state == "published": + certificate.cancel( + record.cancel_reason_id.code, + record.replacement_certificate_id, + record.simulate_operation, + ) + + for invoice in record.certificate_ids.related_invoice_id: + invoice._compute_cfdi_document_id() + + if self.env.company.l10n_mx_cfdi_auto: + invoice.button_draft() + + for payment in record.certificate_ids.related_payment_id: + payment.move_id._compute_cfdi_document_id() diff --git a/l10n_mx_cfdi/wizards/document_cancel_form.xml b/l10n_mx_cfdi/wizards/document_cancel_form.xml new file mode 100644 index 0000000..795d685 --- /dev/null +++ b/l10n_mx_cfdi/wizards/document_cancel_form.xml @@ -0,0 +1,44 @@ + + + Cancelar CFDI + l10n_mx_cfdi.document_cancel + +
+ + + + + + + + + + + + + +
+
+
+
+
+ + + Cancelar CFDI + l10n_mx_cfdi.document_cancel + form + new + +
diff --git a/l10n_mx_cfdi/wizards/download_cfdi_files.py b/l10n_mx_cfdi/wizards/download_cfdi_files.py new file mode 100644 index 0000000..6df7c37 --- /dev/null +++ b/l10n_mx_cfdi/wizards/download_cfdi_files.py @@ -0,0 +1,79 @@ +import base64 +import io +import zipfile + +from odoo import api, fields, models + + +class DownloadCFDIFilesWizard(models.TransientModel): + _name = "l10n_mx_cfdi.download_cfdi_files_wizard" + _description = "Create ZIP file containing selected invoices CFDI" + + invoice_ids = fields.Many2many("account.move", string="Facturas", required=True) + cfdi_document_ids = fields.Many2many( + "l10n_mx_cfdi.document", + string="CFDI Documents", + required=True, + relation="l10n_mx_cfdi_download_cfdi_files_wizard_rel", + ) + + zip_file = fields.Many2one("ir.attachment", readonly=True, ondelete="cascade") + + @api.model + def default_get(self, field_names): + defaults_dict = super().default_get(field_names) + context = self.env.context + + if context["active_model"] == "account.move": + related_invoice_objs = self.env["account.move"].browse( + context["active_ids"] + ) + defaults_dict.update( + { + "invoice_ids": related_invoice_objs, + "cfdi_document_ids": related_invoice_objs.mapped( + "cfdi_document_id" + ), + } + ) + + return defaults_dict + + def _create_zip_file(self): + # prepare zip file + stream = io.BytesIO() + zip_archive = zipfile.ZipFile(stream, "w") + + # add docs to zip file + for cfdi_doc in self.cfdi_document_ids: + if cfdi_doc: + cfdi_doc.download_files_if_needed() + + zip_archive.writestr( + cfdi_doc.pdf_filename, base64.b64decode(cfdi_doc.pdf_file) + ) + zip_archive.writestr( + cfdi_doc.xml_filename, base64.b64decode(cfdi_doc.xml_file) + ) + + zip_archive.close() + + bytes_of_zipfile = stream.getvalue() + + # create attachment + self.zip_file = self.env["ir.attachment"].create( + { + "name": "cfdis.zip", + "datas": base64.b64encode(bytes_of_zipfile), + "type": "binary", + } + ) + + def action_download_zip(self): + self._create_zip_file() + + return { + "type": "ir.actions.act_url", + "url": "/web/content/%s?download=true" % self.zip_file.id, + "target": "self", + } diff --git a/l10n_mx_cfdi/wizards/download_cfdi_files_wizard.xml b/l10n_mx_cfdi/wizards/download_cfdi_files_wizard.xml new file mode 100644 index 0000000..ae8c85b --- /dev/null +++ b/l10n_mx_cfdi/wizards/download_cfdi_files_wizard.xml @@ -0,0 +1,42 @@ + + + + Descargar CFDIs + l10n_mx_cfdi.download_cfdi_files_wizard + +
+

CFDIs Encontrados

+ + + + + + + + + + + +
+
+ +
+
+ + + Descargar CFDIs + l10n_mx_cfdi.download_cfdi_files_wizard + form + new + + list + + +