erickabrego 8 місяців тому
батько
коміт
4aedcd520e
33 змінених файлів з 2981 додано та 0 видалено
  1. 5 0
      custom_sat_connection/__init__.py
  2. 29 0
      custom_sat_connection/__manifest__.py
  3. 3 0
      custom_sat_connection/controllers/__init__.py
  4. 23 0
      custom_sat_connection/controllers/controllers.py
  5. 13 0
      custom_sat_connection/models/__init__.py
  6. 19 0
      custom_sat_connection/models/account_account.py
  7. 831 0
      custom_sat_connection/models/account_cfdi.py
  8. 56 0
      custom_sat_connection/models/account_cfdi_line.py
  9. 17 0
      custom_sat_connection/models/account_cfdi_tax.py
  10. 149 0
      custom_sat_connection/models/account_esignature_certificate.py
  11. 17 0
      custom_sat_connection/models/account_journal.py
  12. 28 0
      custom_sat_connection/models/account_move.py
  13. 882 0
      custom_sat_connection/models/portal_sat.py
  14. 159 0
      custom_sat_connection/models/res_company.py
  15. 7 0
      custom_sat_connection/models/res_config_settings.py
  16. 12 0
      custom_sat_connection/models/res_partner.py
  17. 19 0
      custom_sat_connection/security/ir.model.access.csv
  18. 28 0
      custom_sat_connection/security/res_groups.xml
  19. 25 0
      custom_sat_connection/views/account_account.xml
  20. 227 0
      custom_sat_connection/views/account_cfdi.xml
  21. 61 0
      custom_sat_connection/views/account_esignature_certificate.xml
  22. 23 0
      custom_sat_connection/views/account_journal.xml
  23. 33 0
      custom_sat_connection/views/account_move.xml
  24. 36 0
      custom_sat_connection/views/res_config_settings.xml
  25. 3 0
      custom_sat_connection/wizards/__init__.py
  26. 10 0
      custom_sat_connection/wizards/account_cfdi_link.py
  27. 19 0
      custom_sat_connection/wizards/account_cfdi_sat.py
  28. 44 0
      custom_sat_connection/wizards/account_cfdi_sat.xml
  29. 56 0
      custom_sat_connection/wizards/account_cfdi_xml.py
  30. 30 0
      custom_sat_connection/wizards/account_cfdi_xml.xml
  31. 76 0
      custom_sat_connection/wizards/account_cfdi_zip.py
  32. 38 0
      custom_sat_connection/wizards/account_cfdi_zip.xml
  33. 3 0
      requirements.txt

+ 5 - 0
custom_sat_connection/__init__.py

@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import controllers
+from . import models
+from . import wizards

+ 29 - 0
custom_sat_connection/__manifest__.py

@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+{
+    'name': "Conexión SAT",
+    'summary': """
+        Conexión con el portal del SAT para obtención de complementos CFDI.
+    """,
+    'description': """
+        Conexión con el portal del SAT para obtención de complementos CFDI.
+    """,
+    'author': "M22",
+    'website': "https://m22.mx",
+    'category': 'Account',
+    'version': '18.1',
+    'depends': ['base','account','accountant','l10n_mx_edi'],
+    'data': [
+        'security/res_groups.xml',
+        'security/ir.model.access.csv',
+        'views/account_cfdi.xml',
+        'views/res_config_settings.xml',
+        'views/account_journal.xml',
+        'views/account_account.xml',
+        'views/account_move.xml',
+        'views/account_esignature_certificate.xml',
+        'wizards/account_cfdi_sat.xml',
+        'wizards/account_cfdi_zip.xml',
+        'wizards/account_cfdi_xml.xml',
+    ],
+    'license': 'AGPL-3'
+}

+ 3 - 0
custom_sat_connection/controllers/__init__.py

@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import controllers

+ 23 - 0
custom_sat_connection/controllers/controllers.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from odoo import http
+from odoo.http import request, content_disposition
+import base64
+
+class Binary(http.Controller):
+
+    @http.route('/web/binary/download_document', type='http', auth="public")
+    def download_document(self, model, id, filename=None, **kw):
+
+        record = request.env[model].browse(int(id))
+        binary_file = record.datas # aqui colocas el nombre del campo binario que almacena tu archivo
+        filecontent = base64.b64decode(binary_file or '')
+
+        if not filecontent:
+            return request.not_found()
+        else:
+            if not filename:
+                filename = '%s_%s' % (model.replace('.', '_'), id)
+            content_type = ('Content-Type', 'application/octet-stream')
+            disposition_content = ('Content-Disposition', content_disposition(filename))
+
+        return request.make_response(filecontent, [content_type, disposition_content])

+ 13 - 0
custom_sat_connection/models/__init__.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from . import account_esignature_certificate
+from . import portal_sat
+from . import account_journal
+from . import account_account
+from . import account_cfdi
+from . import account_cfdi_line
+from . import account_cfdi_tax
+from . import res_company
+from . import res_partner
+from . import res_config_settings
+from . import account_move

+ 19 - 0
custom_sat_connection/models/account_account.py

@@ -0,0 +1,19 @@
+from odoo import api, fields, models
+
+class AccountAccount(models.Model):
+    _inherit = "account.account"
+
+    x_cfdi_type = fields.Selection([
+        ('I', 'Facturas de clientes'),
+        ('SI', 'Facturas de proveedor'),
+        ('E', 'Notas de crédito cliente'),
+        ('SE', 'Notas de crédito proveedor'),
+        ('P', 'REP de clientes'),
+        ('SP', 'REP de proveedores'),
+        ('N', 'Nóminas de empleados'),
+        ('SN', 'Nómina propia'),
+        ('T', 'Factura de traslado cliente'),
+        ('ST', 'Factura de traslado proveedor'),
+    ], string='Tipo de comprobante')
+
+

+ 831 - 0
custom_sat_connection/models/account_cfdi.py

@@ -0,0 +1,831 @@
+from odoo import api, fields, models, _, Command
+from odoo.exceptions import ValidationError
+from collections import OrderedDict
+from datetime import date
+from os.path import basename
+from zipfile import ZipFile
+from tempfile import TemporaryDirectory
+import xmltodict
+import base64
+import logging
+import os.path
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountCFDI(models.Model):
+    _name = 'account.cfdi'
+    _inherit = ['mail.thread', 'mail.activity.mixin']
+    _description = 'Complemento CFDI'
+
+    code = fields.Char(string="Código")
+    name = fields.Char(string="Referencia")
+    uuid = fields.Char(string="UUID")
+    certificate = fields.Char(string="Certificado")
+    certificate_number = fields.Char(string="Nro. de certificado")
+    serie = fields.Char(string="Serie")
+    folio = fields.Char(string="Folio")
+    stamp = fields.Char(string="Sello")
+    version = fields.Char(string="Versión")
+    payment_condition = fields.Char(string="Condiciones de pago")
+    currency = fields.Char(string="Moneda")
+    payment_method = fields.Char(string="Forma de pago")
+    location = fields.Char(string="Lugar de expedición")
+    observations = fields.Text(string='Notas')
+    attachment_id = fields.Many2one(comodel_name="ir.attachment", string="Archivo adjunto")
+    pdf_id = fields.Many2one(comodel_name="ir.attachment", string="Representación impresa")
+    company_id = fields.Many2one(comodel_name="res.company", string="Empresa", default=lambda self: self.env.company)
+    emitter_id = fields.Many2one(comodel_name="res.partner", string="Emisor")
+    receiver_id = fields.Many2one(comodel_name="res.partner", string="Receptor")
+    move_id = fields.Many2one(comodel_name="account.move", string="Movimiento contable", copy=False)
+    payable_account_id = fields.Many2one(comodel_name='account.account', string='Cuenta contable a pagar', tracking=True)
+    account_id = fields.Many2one(comodel_name='account.account', string='Cuenta contable gasto', tracking=True)
+    account_analytic_account_id = fields.Many2one(comodel_name='account.analytic.account', string='Cuenta analítica')
+    fiscal_position_id = fields.Many2one(comodel_name='account.fiscal.position', string='Posición fiscal')
+    journal_id = fields.Many2one(comodel_name='account.journal', string='Diario', tracking=True)
+    tax_isr_id = fields.Many2one(comodel_name='account.tax', string='Retención ISR')
+    tax_iva_id = fields.Many2one(comodel_name='account.tax', string='Retención IVA')
+    analytic_distribution = fields.Json(string="Distribución analítica")
+    analytic_precision = fields.Integer(string="Precisión analítica", store=False, default=lambda self: self.env['decimal.precision'].precision_get("Percentage Analytic"))
+    concept_ids = fields.One2many("account.cfdi.line", "cfdi_id", string="Conceptos")
+    tax_ids = fields.One2many('account.cfdi.tax', 'cfdi_id', string='Impuestos')
+    date = fields.Date(string="Fecha")
+    subtotal = fields.Float(string="Subtotal", copy=False)
+    total = fields.Float(string="Total", copy=False)
+    tax_total = fields.Float(string="Impuestos", compute="compute_tax_total", store=True)
+    payment_type = fields.Selection(selection=[('PPD', 'PPD'), ('PUE', 'PUE')], string='Método de pago', readonly=True)
+    cfdi_type = fields.Selection(string="Tipo de comprobante", selection=[
+        ('I', 'Facturas de clientes'),
+        ('SI', 'Facturas de proveedor'),
+        ('E', 'Notas de crédito cliente'),
+        ('SE', 'Notas de crédito proveedor'),
+        ('P', 'REP de clientes'),
+        ('SP', 'REP de proveedores'),
+        ('N', 'Nóminas de empleados'),
+        ('SN', 'Nómina propia'),
+        ('T', 'Factura de traslado cliente'),
+        ('ST', 'Factura de traslado proveedor')
+    ], index=True, copy=False)
+    state = fields.Selection(string="Estado", selection=[
+        ('draft', 'Borrador'),
+        ('done', 'Procesada'),
+        ('cancel', 'Anulado')
+    ], copy=False, default='draft', tracking=True)
+    sat_state = fields.Selection(string="Estado SAT", selection=[
+        ("valid", "Valido"),
+        ("not_found", "No Encontrado"),
+        ("undefined", "No sincronizado Aún"),
+        ("none", "Estado no definido"),
+        ("cancelled", "Cancelado")
+    ], copy=False, tracking=True, default="valid")
+    #Datos de addenda
+    delivery_number = fields.Char(string="No. Entrega")
+    invoice_qty = fields.Integer(string="Unidades facturadas")
+
+    @api.depends("subtotal","total")
+    def compute_tax_total(self):
+        for rec in self:
+            rec.tax_total = rec.total - rec.subtotal
+
+    # ---------------------------------------------------Metodos de creación---------------------------------------------------------
+
+    @api.model
+    def create(self, vals_list):
+        self = self.with_context(skip_invoice_sync=True, check_move_validity=False)
+        res = super().create(vals_list)
+        for cfdi in res.filtered(lambda move: move.move_id):
+            cfdi.move_id.update({
+                "cfdi_id": cfdi.id
+            })
+        return res
+
+    def name_get(self):
+        result = []
+        for record in self:
+            folio = f"[{record.serie}-{record.folio}] - " if record.serie and record.folio else f"[{record.serie}] - " if record.serie and not record.folio else f"[{record.folio}] - " if not record.serie and record.folio else ""
+            name = f"{folio}" + record.uuid + ' - $' + str(record.total)
+            result.append((record.id, name))
+        return result
+
+    # Creación de CFDIS
+    def create_cfdis(self, attachment_data):
+        self = self.with_context(skip_invoice_sync=True, check_move_validity=False)
+        cfdi_list = []
+        cfdi_ids = self.env["account.cfdi"]
+        uuids = []
+        for data in attachment_data:
+            # Obtener la información para crear el cfdi
+            data_xml = data.get("xml")
+            xml_data = self.get_cfdi_data(data_xml.get("datas"))
+            if xml_data.get("Comprobante"):
+                cfdi_type = self.get_cfdi_type(xml_data)
+                uuid = self.validation_cfdi(xml_data, cfdi_type)
+                # Evitar UUID repetidos ya que el SAT manda en ocasiones el mismo XML dos veces
+                if uuid and uuid not in uuids:
+                    uuids.append(uuid)
+                    emiiter_partner_id, recipient_partner_id = self.get_cfdi_partners(xml_data)
+                    move_id = self.validate_cfdi_move(xml_data, uuid, cfdi_type, emiiter_partner_id)
+                    cfdi_data = self.add_cfdi_data(xml_data, uuid, emiiter_partner_id, recipient_partner_id, move_id, cfdi_type, data)
+                    cfdi_data = self.get_cfdi_lines(xml_data, cfdi_data, cfdi_type, emiiter_partner_id, recipient_partner_id)
+                    # cfdi_data = self.get_payment_tax(cfdi_type, xml_data, cfdi_data)
+                    cfdi_list.append(cfdi_data)
+                else:
+                    continue
+        if cfdi_list:
+            cfdi_ids = self.env["account.cfdi"].sudo().create(cfdi_list)
+        return cfdi_ids
+
+    # Obtener la información del xml
+    def get_cfdi_data(self, xml_file):
+        file_content = base64.b64decode(xml_file)
+        if b'xmlns:schemaLocation' in file_content and b'xsi:schemaLocation' not in file_content:
+            file_content = file_content.replace(b'xmlns:schemaLocation', b'xsi:schemaLocation')
+        file_content = file_content.replace(b'cfdi:', b'')
+        file_content = file_content.replace(b'tfd:', b'')
+        try:
+            xml_data = xmltodict.parse(file_content)
+            return xml_data
+        except Exception as e:
+            _logger.info(e)
+            return dict()
+
+    # Obtener el tipo de comprobante que es el CFDI
+    def get_cfdi_type(self, xml_data):
+        cfdi_type = xml_data['Comprobante']['@TipoDeComprobante'] if '@TipoDeComprobante' in xml_data['Comprobante'] else 'I'
+        if cfdi_type in ['I', 'E', 'P']:
+            if xml_data['Comprobante']['Emisor']['@Rfc'] != self.env.company.vat:
+                cfdi_type = 'S' + cfdi_type
+        return cfdi_type
+
+    # Validaciones antes de la creación del CFDI
+    def validation_cfdi(self, xml_data, cfdi_type):
+        if '@UUID' in xml_data['Comprobante']['Complemento']['TimbreFiscalDigital']:
+            uuid = xml_data['Comprobante']['Complemento']['TimbreFiscalDigital']['@UUID']
+            cfdi_id = self.env['account.cfdi'].sudo().search([('uuid', '=', uuid)], limit=1)
+            if cfdi_id:
+                _logger.info(f"El CFDI con UUID {uuid}, ya existe en la base de datos, se omitirá en el proceso.")
+                return False
+            # Evitar que se suban xml que no pertenecen a la empresa
+            if "S" in cfdi_type:
+                partner_rfc = xml_data['Comprobante']['Receptor']['@Rfc']
+            else:
+                partner_rfc = xml_data['Comprobante']['Emisor']['@Rfc']
+            if partner_rfc != self.env.company.vat:
+                return False
+        else:
+            return False
+        return uuid
+
+    # Buscar el asiento contable de odoo relacionado si es que existe
+    def validate_cfdi_move(self, xml_data, uuid, cfdi_type, emitter_partner_id):
+        move_id = self.env["account.move"]
+        delivery_number, invoice_qty = self.get_addenda_data(xml_data)
+        if cfdi_type in ["I", "E"]:
+            move_id = move_id.sudo().search(
+                [("move_type", "in", ["out_invoice", "out_refund"]), ("l10n_mx_edi_cfdi_uuid", "=", uuid),
+                 ("state", "=", "posted"), ("cfdi_id", "=", False)], limit=1)
+        elif cfdi_type in ["SI", "SE"]:
+            invoice_date = xml_data['Comprobante']['@Fecha'] if '@Fecha' in xml_data['Comprobante'] else ""
+            folio = xml_data['Comprobante']['@Folio'] if '@Folio' in xml_data['Comprobante'] else ''
+            move_id = self.env['account.move'].sudo().search(
+                [('partner_id.vat', '=', emitter_partner_id.vat), ('ref', '=', folio),
+                 ('move_type', 'in', ['in_invoice', 'in_refund']), ("state", "=", "posted"), ("cfdi_id", "=", False),
+                 ("invoice_date", "=", invoice_date[:10])], limit=1)
+            if not move_id:
+                if '@Serie' in xml_data['Comprobante']:
+                    folio = xml_data['Comprobante']['@Serie'] + folio
+                    move_id = self.env['account.move'].sudo().search(
+                        [('partner_id.vat', '=', emitter_partner_id.vat), ('ref', '=', folio),
+                         ('move_type', 'in', ['in_invoice', 'in_refund']), ("cfdi_id", "=", False),
+                         ("state", "=", "posted"), ("invoice_date", "=", invoice_date[:10])], limit=1)
+                if not move_id:
+                    move_id = self.env['account.move'].sudo().search(
+                        [('partner_id.vat', '=', emitter_partner_id.vat),
+                         ('move_type', 'in', ['in_invoice', 'in_refund']), ("state", "=", "posted"),
+                         ("cfdi_id", "=", False),
+                         ("invoice_date", "=", invoice_date[:10])], limit=1)
+                if not move_id and delivery_number:
+                    move_id = self.env['account.move'].sudo().search(
+                        [('partner_id.vat', '=', emitter_partner_id.vat),
+                         ('move_type', 'in', ['in_invoice', 'in_refund']), ("state", "=", "posted"),
+                         ("cfdi_id", "=", False), ("x_delivery_number","!=",False),
+                         ("x_delivery_number", "=", delivery_number)], limit=1)
+                if not move_id:
+                    amount_untaxed = xml_data['Comprobante']['@SubTotal'] if '@SubTotal' in xml_data['Comprobante'] else 0
+                    invoice_date = xml_data['Comprobante']['@Fecha'] if '@Fecha' in xml_data['Comprobante'] else ""
+                    move_id = self.env['account.move'].sudo().search(
+                        [('partner_id.vat', '=', emitter_partner_id.vat), ('move_type', 'in', ['in_invoice']),
+                         ("cfdi_id", "=", False), ("state", "=", "posted"), ("amount_untaxed", "=", amount_untaxed),
+                         ("invoice_date", "=", invoice_date[:10])], limit=1)
+        return move_id
+
+    # Preparar la informacion del CFDI
+    def add_cfdi_data(self, xml_data, uuid, emitter_partner_id, recipient_partner_id, move_id, cfdi_type, attachment_data):
+        journal_id, account_id = self.get_cfdi_journal_id(cfdi_type, emitter_partner_id, recipient_partner_id)
+        partner_id = recipient_partner_id if cfdi_type in ["I", "E"] else emitter_partner_id
+        payable_account_id = self.get_payable_cfdi_account_id(cfdi_type, partner_id)
+        attachment_id = self.env["ir.attachment"].sudo().create(attachment_data.get("xml"))
+        pdf_id = self.env["ir.attachment"].sudo().create(attachment_data.get("pdf")) if attachment_data.get("pdf") else False
+        delivery_number, invoice_qty = self.get_addenda_data(xml_data)
+        
+        data = {
+            "attachment_id": attachment_id.id,
+            "pdf_id": pdf_id.id if pdf_id else False,
+            "code": uuid,
+            "uuid": uuid,
+            "certificate": xml_data['Comprobante']['@Certificado'] if '@Certificado' in xml_data['Comprobante'] else '',
+            "date": xml_data['Comprobante']['@Fecha'] if '@Fecha' in xml_data['Comprobante'] else '',
+            "folio": xml_data['Comprobante']['@Folio'] if '@Folio' in xml_data['Comprobante'] else '',
+            "payment_method": xml_data['Comprobante']['@FormaPago'] if '@FormaPago' in xml_data['Comprobante'] else '',
+            "location": xml_data['Comprobante']['@LugarExpedicion'] if '@LugarExpedicion' in xml_data['Comprobante'] else '',
+            "payment_type": xml_data['Comprobante']['@MetodoPago'] if '@MetodoPago' in xml_data['Comprobante'] else '',
+            "currency": xml_data['Comprobante']['@Moneda'] if '@Moneda' in xml_data['Comprobante'] else '',
+            "certificate_number": xml_data['Comprobante']['@NoCertificado'] if '@NoCertificado' in xml_data['Comprobante'] else '',
+            "stamp": xml_data['Comprobante']['@Sello'] if '@Sello' in xml_data['Comprobante'] else '',
+            "serie": xml_data['Comprobante']['@Serie'] if '@Serie' in xml_data['Comprobante'] else '',
+            "subtotal": xml_data['Comprobante']['@SubTotal'] if '@SubTotal' in xml_data['Comprobante'] else '',
+            "cfdi_type": cfdi_type,
+            "total": xml_data['Comprobante']['@Total'] if '@Total' in xml_data['Comprobante'] else '',
+            "version": xml_data['Comprobante']['@Version'] if '@Version' in xml_data['Comprobante'] else '',
+            "payment_condition": xml_data['Comprobante']['@CondicionesDePago'] if '@CondicionesDePago' in xml_data['Comprobante'] else '',
+            "emitter_id": emitter_partner_id.id,
+            "receiver_id": recipient_partner_id.id,
+            "move_id": move_id.id if move_id else False,
+            "state": "draft" if not move_id else "done",
+            "journal_id": journal_id.id if journal_id else False,
+            "account_id": account_id.id if account_id else False,
+            "fiscal_position_id": partner_id.property_account_position_id.id if partner_id.property_account_position_id else False,
+            "tax_iva_id": partner_id.x_tax_iva_id.id if partner_id.x_tax_iva_id else False,
+            "tax_isr_id": partner_id.x_tax_isr_id.id if partner_id.x_tax_isr_id else False,
+            "payable_account_id": payable_account_id.id if payable_account_id else False,
+        }
+        data["name"] = data["serie"] + data["folio"] if data.get("serie") and data.get("folio") else data["serie"] if data.get("serie") else data.get("folio")
+        data["delivery_number"] = delivery_number
+        data["invoice_qty"] = invoice_qty
+        return data
+
+    # Se obtienen las lineas de CFDI
+    def get_cfdi_lines(self, xml_data, cfdi_data, cfdi_type, emitter_partner_id, recipient_partner_id):
+        if type(xml_data['Comprobante']['Conceptos']['Concepto']) is list:
+            lines = xml_data['Comprobante']['Conceptos']['Concepto']
+        elif type(xml_data['Comprobante']['Conceptos']['Concepto']) is OrderedDict:
+            lines = xml_data['Comprobante']['Conceptos'].items()
+        else:
+            lines = [xml_data['Comprobante']['Conceptos']['Concepto']]
+        i = 1
+        data_list = []
+        for line_value in lines:
+            if type(xml_data['Comprobante']['Conceptos']['Concepto']) is list:
+                line = line_value
+            elif type(xml_data['Comprobante']['Conceptos']['Concepto']) is OrderedDict:
+                line = line_value[1]
+            else:
+                line = line_value
+            if float(line['@Importe']) >= 0:
+                uom_id = False
+                if '@ClaveUnidad' in line:
+                    uom_unspsc_id = self.env['product.unspsc.code'].sudo().search([('code', '=', line['@ClaveUnidad'])])
+                    if uom_unspsc_id:
+                        uom_id = self.env['uom.uom'].sudo().search([('unspsc_code_id', '=', uom_unspsc_id.id)], limit=1)
+                if uom_id:
+                    unidad_id = uom_id.id
+                else:
+                    unidad_id = False
+                product_category_id = False
+                if '@ClaveProdServ' in line:
+                    unspsc_product_category_id = self.env['product.unspsc.code'].sudo().search([('code', '=', line['@ClaveProdServ'])])
+                    if unspsc_product_category_id:
+                        product_category_id = unspsc_product_category_id.id
+                    else:
+                        product_category_id = False
+
+                data_line = {
+                    'sequence': i,
+                    'code_cfdi': cfdi_data.get("code"),
+                    'date': cfdi_data.get("date"),
+                    'folio': cfdi_data.get("folio"),
+                    'payment_method': cfdi_data.get("payment_method"),
+                    'location': cfdi_data.get("location"),
+                    'payment_type': cfdi_data.get("payment_type"),
+                    'currency': cfdi_data.get("currency"),
+                    'certificate_number': cfdi_data.get("certificate_number"),
+                    'stamp': cfdi_data.get("stamp"),
+                    'serie': cfdi_data.get("serie"),
+                    'subtotal': cfdi_data.get("subtotal"),
+                    'cfdi_type': cfdi_data.get("cfdi_type"),
+                    'total': cfdi_data.get("total"),
+                    'version': cfdi_data.get("version"),
+                    'emitter_id': cfdi_data.get("emitter_id"),
+                    'receiver_id': cfdi_data.get("receiver_id"),
+                    'product_code': line['@ClaveProdServ'] if '@ClaveProdServ' in line else '',
+                    'no_identification': line['@NoIdentificacion'] if '@NoIdentificacion' in line else '',
+                    'quantity': float(line['@Cantidad']),
+                    'uom_code': line['@ClaveUnidad'] if '@ClaveUnidad' in line else '',
+                    'uom': line['@Unidad'] if '@Unidad' in line else '',
+                    'description': line['@Descripcion'],
+                    'discount': float(line['@Descuento']) if '@Descuento' in line else 0,
+                    'unit_price': float(line['@ValorUnitario']),
+                    'uom_id': unidad_id,
+                    'unspsc_product_category_id': product_category_id,
+                    'amount': float(line['@Importe']),
+                }
+                data_line = self.search_cfdi_product(line, cfdi_type, data_line, emitter_partner_id, recipient_partner_id)
+                data_line = self.get_cfdi_tax_lines(data_line, line, cfdi_type, recipient_partner_id)
+                data_list.append(Command.create(data_line))
+            i += 1
+        if data_list:
+            cfdi_data["concept_ids"] = data_list
+        return cfdi_data
+
+    #Obtener intereses de pago
+    def get_payment_tax(self, cfdi_type, xml_data, cfdi_data):
+        if cfdi_type in ['P', 'SP']:
+            payment_tax_list = []
+            payments_list = xml_data['Comprobante']['Complemento']['pago20:Pagos']['pago20:Pago']
+            payments_list = self.get_data_iterable(payments_list)
+            if payments_list:
+                for payment_list in payments_list:
+                    payment_date = payment_list["@FechaPago"][:10],
+                    payments = payment_list["pago20:DoctoRelacionado"]
+                    payments = self.get_data_iterable(payments)
+
+                    if payments:
+                        for payment in payments:
+                            if payment.get("@ObjetoImpDR") and payment.get("@ObjetoImpDR") == '02':
+                                payment_taxes = payment["pago20:ImpuestosDR"]["pago20:TrasladosDR"]["pago20:TrasladoDR"]
+                                payment_taxes = self.get_data_iterable(payment_taxes)
+                                if payment_taxes:
+                                    for payment_tax in payment_taxes:
+                                        payment_tax_data = {
+                                            "name": payment["@IdDocumento"],
+                                            "serie": payment.get("@Serie"),
+                                            "folio": payment.get("@Folio"),
+                                            "currency": payment["@MonedaDR"],
+                                            "currency_rate": payment["@EquivalenciaDR"],
+                                            "paid_amount": payment["@ImpPagado"],
+                                            "previous_balance": payment["@ImpSaldoAnt"],
+                                            "current_balance": payment["@ImpSaldoInsoluto"],
+                                            "subject_tax": payment["@ObjetoImpDR"],
+                                            "payment_date": payment_date[0],
+                                            "tax_amount": payment_tax.get("@ImporteDR"),
+                                            "base_amount": payment_tax["@BaseDR"],
+                                            "type_tax": payment_tax["@ImpuestoDR"],
+                                            "base_tax": float(payment_tax["@TasaOCuotaDR"]) * 100 if payment_tax.get("@TasaOCuotaDR") else 0,
+                                            "exempt_tax": True if payment_tax.get("@TipoFactorDR") == 'Exento' else False,
+                                        }
+                                        payment_tax_list.append(Command.create(payment_tax_data))
+            if payment_tax_list:
+                cfdi_data["tax_paymnent_ids"] = payment_tax_list
+        return cfdi_data
+
+    def get_addenda_data(self, xml_data):
+        try:
+            addenda = xml_data["Comprobante"]["Addenda"]
+            addenda_header = addenda["customized"]["NEW_ERA"]["Cabecera"]
+            addenda_footer = addenda["customized"]["NEW_ERA"]["DatosPie"]
+            return addenda_header["@DL_VBLEN"], addenda_footer["@SUMQTYEA"]
+        except:
+            return False, False
+            
+    #Obtener el producto del cfdi si existe
+    def search_cfdi_product(self, line, cfdi_type, data_line, emitter_partner_id, recipient_partner_id):
+        partner_id = recipient_partner_id if cfdi_type in ["I", "E"] else emitter_partner_id
+        product_tmpl_id = self.env['product.template']
+        concept_id = self.env['account.cfdi.line']
+        account_line_id = self.env['account.move.line']
+        # Buscar el producto para poder relacionarlo a la linea del concepto
+        if '@NoIdentificacion' in line:
+            # Si el comprobante es una factura de proveedor o una nota de cliente del proveedor
+            if cfdi_type in ['SI', 'SE']:
+                # Se busca si es que se cuenta con una lista de precios a proveedor que identifique el producto mediante el codigo del producto
+                product_supplier_id = self.env['product.supplierinfo'].sudo().search([('partner_id', '=', partner_id.id), ('product_code', '=', line['@NoIdentificacion'])], limit=1)
+                if product_supplier_id:
+                    product_tmpl_id = product_supplier_id.product_tmpl_id
+            if not product_tmpl_id and cfdi_type in ["I", "E"]:
+                product_tmpl_id = self.env['product.template'].sudo().search([('default_code', '=', line['@NoIdentificacion'])], limit=1)
+        # Identificar si se tiene registro de algun producto que coincida exactamente con la descripción del concepto
+        if not product_tmpl_id and '@Descripcion' in line:
+            if cfdi_type in ['SI', 'SE']:
+                product_supplier_id = self.env['product.supplierinfo'].sudo().search([('partner_id', '=', partner_id.id), ('product_name', '=', line['@Descripcion'])], limit=1)
+                if product_supplier_id:
+                    product_tmpl_id = product_supplier_id.product_tmpl_id
+            elif cfdi_type in ['I', 'E']:
+                product_tmpl_id = self.env['product.template'].sudo().search(['|', ("name", "=", line['@Descripcion']), ('default_code', '=', line['@Descripcion'])], limit=1)
+        # Se busca el producto si ya ha habido lineas de producto con el mismo emisor y misma clave de producto o descripcion
+        if not product_tmpl_id and '@ClaveProdServ' in line and partner_id:
+            concept_id = self.env['account.cfdi.line'].sudo().search([("product_tmpl_id", "!=", False), ("emitter_id.id", "=", partner_id.id if cfdi_type in ["SI", "SE"] else emitter_partner_id.id), ("product_code", "=", line['@ClaveProdServ']), ("company_id.id", "=", self.env.company.id)], limit=1)
+            if concept_id:
+                product_tmpl_id = concept_id.product_tmpl_id
+            else:
+                account_line_id = self.env["account.move.line"].sudo().search([("product_id", "!=", False), ("product_id.unspsc_code_id.code", "=", line['@ClaveProdServ']), ("partner_id.id", "=", partner_id.id)], limit=1)
+                if account_line_id:
+                    product_tmpl_id = account_line_id.product_id.product_tmpl_id
+        # Identificar si existen lineas de conceptos que coincidan con la misma descripción y del mismo emisor
+        if not product_tmpl_id and '@Descripcion' in line and partner_id:
+            concept_id = self.env['account.cfdi.line'].sudo().search([("product_tmpl_id", "!=", False), ("emitter_id.id", "=", partner_id.id if cfdi_type in ["SI", "SE"] else emitter_partner_id.id), ("description", "=", line['@Descripcion']), ("company_id.id", "=", self.env.company.id)], limit=1)
+            if concept_id:
+                product_tmpl_id = concept_id.product_tmpl_id
+        if not product_tmpl_id and partner_id and partner_id.x_product_tmpl_id:
+            product_tmpl_id = partner_id.x_product_tmpl_id
+
+        if product_tmpl_id:
+            product_id = self.env['product.product'].sudo().search([('product_tmpl_id', '=', product_tmpl_id.id)], limit=1)
+            data_line["product_id"] = product_id.id if product_id else False
+            data_line["product_tmpl_id"] = product_tmpl_id.id
+            categ_id = product_tmpl_id.categ_id
+            if cfdi_type in ["SI", "SE"]:
+                account_id = product_tmpl_id.property_account_expense_id if product_tmpl_id.property_account_expense_id else categ_id.property_account_expense_categ_id if categ_id else False
+            elif cfdi_type in ["I", "E"]:
+                account_id = product_tmpl_id.property_account_income_id if product_tmpl_id.property_account_income_id else categ_id.property_account_income_categ_id if categ_id else False
+            data_line["account_id"] = concept_id.account_id.id if concept_id and concept_id.account_id else account_line_id.account_id.id if account_line_id else account_id.id if account_id else False
+        return data_line
+
+    #Obtener lineas de impuestos
+    def get_cfdi_tax_lines(self, data_line, line, cfdi_type, recipient_partner_id):
+        tax_list = []
+        if 'Impuestos' in line:
+            j = 1
+            if 'Traslados' in line['Impuestos']:
+                if type(line['Impuestos']['Traslados']['Traslado']) is list:
+                    impuestos = line['Impuestos']['Traslados']['Traslado']
+                elif type(line['Impuestos']['Traslados']['Traslado']) is OrderedDict:
+                    impuestos = line['Impuestos']['Traslados'].items()
+                else:
+                    impuestos = [line['Impuestos']['Traslados']['Traslado']]
+                for value_tax in impuestos:
+                    if type(line['Impuestos']['Traslados']['Traslado']) is list:
+                        impuesto = value_tax
+                    elif type(line['Impuestos']['Traslados']['Traslado']) is OrderedDict:
+                        impuesto = value_tax[1]
+                    else:
+                        impuesto = value_tax
+                    if str(impuesto['@TipoFactor']).strip().upper() == 'EXENTO':
+                        tasa_o_cuota = 0
+                        importe = 0
+                    else:
+                        tasa_o_cuota = float(impuesto['@TasaOCuota'])
+                        importe = float(impuesto['@Importe'])
+                    tax_data = {
+                        'sequence': j,
+                        'base': float(impuesto['@Base']),
+                        'code': impuesto['@Impuesto'],
+                        'factor_type': impuesto['@TipoFactor'],
+                        'rate': tasa_o_cuota,
+                        'amount': importe,
+                        'tax_type': 'traslado'
+                    }
+                    j = j + 1
+                    amount = tasa_o_cuota * 100
+                    tax_domain = [('amount', '=', amount), ('company_id', '=', self.env.company.id)]
+                    t_id = self.env["account.tax"]
+                    if tax_data.get("impuesto") == '002':
+                        tax_domain.append(("name", "ilike", "iva"))
+                    elif tax_data.get("impuesto") == '003':
+                        tax_domain.append(("name", "ilike", "ieps"))
+                    elif tax_data.get("impuesto") == '001':
+                        tax_domain.append(("name", "ilike", "isr"))
+
+                    if cfdi_type in ['I', 'E']:
+                        tax_domain.append(('type_tax_use', '=', 'sale'))
+                        t_id = self.env['account.tax'].sudo().search(tax_domain, limit=1)
+                    elif cfdi_type in ['SI', 'SE']:
+                        tax_domain.append(('type_tax_use', '=', 'purchase'))
+                        t_id = self.env['account.tax'].sudo().search(tax_domain, limit=1)
+                    if t_id:
+                        tax_data["tax_id"] = t_id.id
+                    tax_list.append(Command.create(tax_data))
+
+            if 'Retenciones' in line['Impuestos']:
+                if type(line['Impuestos']['Retenciones']['Retencion']) is list:
+                    retenciones = line['Impuestos']['Retenciones']['Retencion']
+                elif type(line['Impuestos']['Retenciones']['Retencion']) is OrderedDict:
+                    retenciones = line['Impuestos']['Retenciones'].items()
+                else:
+                    retenciones = [line['Impuestos']['Retenciones']['Retencion']]
+                for value_tax in retenciones:
+                    if type(line['Impuestos']['Retenciones']['Retencion']) is list:
+                        retencion = value_tax
+                    elif type(line['Impuestos']['Retenciones']['Retencion']) is OrderedDict:
+                        retencion = value_tax[1]
+                    else:
+                        retencion = value_tax
+                    tasa_o_cuota = float(retencion['@TasaOCuota'])
+                    importe = float(retencion['@Importe'])
+                    tax_data = {
+                        'sequence': j,
+                        'base': float(retencion['@Base']),
+                        'code': retencion['@Impuesto'],
+                        'factor_type': retencion['@TipoFactor'],
+                        'rate': tasa_o_cuota,
+                        'amount': importe,
+                        'tax_type': 'retencion'
+                    }
+                    j = j + 1
+
+                    amount = round(-(tasa_o_cuota * 100), 2)
+                    tax_domain = [('amount', '=', amount), ('company_id', '=', self.env.company.id)]
+                    t_id = self.env['account.tax']
+                    if tax_data.get("impuesto") == '002':
+                        tax_domain.append(("name", "ilike", "iva"))
+                    elif tax_data.get("impuesto") == '003':
+                        tax_domain.append(("name", "ilike", "ieps"))
+                    elif tax_data.get("impuesto") == '001':
+                        tax_domain.append(("name", "ilike", "isr"))
+                    if cfdi_type in ['I', 'E']:
+                        tax_domain.append(('type_tax_use', '=', 'sale'))
+                        t_id = self.env['account.tax'].sudo().search(tax_domain, limit=1)
+                    elif cfdi_type in ['SI', 'SE']:
+                        tax_domain.append(('type_tax_use', '=', 'purchase'))
+                        t_id = self.env['account.tax'].sudo().search(tax_domain, limit=1)
+                    if t_id:
+                        tax_data["tax_id"] = t_id.id
+
+                    if not t_id and cfdi_type in ['SI', 'SE']:
+                        if tax_data.get("impuesto") == '001':
+                            if recipient_partner_id.tax_isr_id:
+                                tax_data["tax_id"] = recipient_partner_id.tax_isr_id.id
+                        elif tax_data.get("impuesto") == '002':
+                            if recipient_partner_id.tax_iva_id:
+                                tax_data["tax_id"] = recipient_partner_id.tax_iva_id.id
+                    tax_list.append(Command.create(tax_data))
+            if tax_list:
+                data_line["tax_ids"] = tax_list
+        return data_line
+
+    # Obteniendo el diario contable dependiendo del tipo de comprobante
+    def get_cfdi_journal_id(self, cfdi_type, emitter_partner_id, recipient_partner_id):
+        journal_id = self.env['account.journal'].sudo().search([('x_cfdi_type', '=', cfdi_type), ("company_id.id", "=", self.env.company.id)], limit=1)
+        # Buscamos si ya hubo un CFDI al mismo cliente y receptor y del mismo tipo y que tenga un diario colocado
+        if not journal_id:
+            cfdi_id = self.env["account.cfdi"].sudo().search(
+                [("emitter_id.id", "=", emitter_partner_id.id),
+                 ("receiver_id.id", "=", recipient_partner_id.id), ("cfdi_type", "=", cfdi_type),
+                 ("journal_id", "!=", False)], limit=1)
+            journal_id = cfdi_id.journal_id if cfdi_id else False
+        account_id = journal_id.default_account_id if journal_id and journal_id.default_account_id else False
+        if not account_id:
+            partner_id = recipient_partner_id if cfdi_type in ["I", "E"] else emitter_partner_id
+            account_id = self.get_expense_cfdi_account_id(cfdi_type, partner_id)
+        return journal_id, account_id
+
+    # Obtener la cuenta de gastos
+    def get_expense_cfdi_account_id(self, cfdi_type, partner_id):
+        if partner_id:
+            expense_account_id = partner_id.x_account_expense_id
+            # Buscamos un CFDI parecido para obtener la cuenta por pagar
+            if not expense_account_id and cfdi_type in ['I', 'E']:
+                cfdi_id = self.env["account.cfdi"].sudo().search(
+                    [("receiver_id.id", "=", partner_id.id), ("cfdi_type", "=", cfdi_type),
+                     ("account_id", "!=", False)], limit=1)
+                expense_account_id = cfdi_id.payable_account_id if cfdi_id else False
+            elif not expense_account_id and cfdi_type in ['SI', 'SE']:
+                cfdi_id = self.env["account.cfdi"].sudo().search(
+                    [("emitter_id.id", "=", partner_id.id), ("cfdi_type", "=", cfdi_type),
+                     ("account_id", "!=", False)], limit=1)
+                expense_account_id = cfdi_id.payable_account_id if cfdi_id else False
+        return expense_account_id
+
+    # Obtener cuenta por pagar dependiendo del tipo de comprobante
+    def get_payable_cfdi_account_id(self, cfdi_type, partner_id):
+        # Se busca si se tiene configurado la cuenta por pagar en la cuenta sino se busca por el contacto y por ultimo se busca un cfdi con el mismo contacto y tipo
+        payable_account_id = self.env['account.account'].sudo().search([('x_cfdi_type', '=', cfdi_type), ("company_ids.id", "=", self.env.company.id)], limit=1)
+        if not payable_account_id and partner_id:
+            payable_account_id = partner_id.property_account_payable_id
+            # Buscamos un CFDI parecido para obtener la cuenta por pagar
+            if not payable_account_id and cfdi_type in ['I', 'E']:
+                cfdi_id = self.env["account.cfdi"].sudo().search(
+                    [("receiver_id.id", "=", partner_id.id), ("cfdi_type", "=", cfdi_type),
+                     ("payable_account_id", "!=", False)], limit=1)
+                payable_account_id = cfdi_id.payable_account_id if cfdi_id else False
+            elif not payable_account_id and cfdi_type in ['SI', 'SE']:
+                cfdi_id = self.env["account.cfdi"].sudo().search(
+                    [("emitter_id.id", "=", partner_id.id), ("cfdi_type", "=", cfdi_type),
+                     ("payable_account_id", "!=", False)], limit=1)
+                payable_account_id = cfdi_id.payable_account_id if cfdi_id else False
+        return payable_account_id
+
+    # Obtener el emisor y receptor del cfdi
+    def get_cfdi_partners(self, xml_data):
+        emitter_id = self.env['res.partner'].sudo().search([('vat', '=', xml_data['Comprobante']['Emisor']['@Rfc'])],	limit=1)
+        receiver_id = self.env['res.partner'].sudo().search([('vat', '=', xml_data['Comprobante']['Receptor']['@Rfc'])], limit=1)
+        if not emitter_id:
+            emitter_id = self.create_cfdi_partner(xml_data, "Emisor")
+        if not receiver_id:
+            receiver_id = self.create_cfdi_partner(xml_data, "Receptor")
+        return emitter_id, receiver_id
+
+    # Crear los contactos del CFDI si es necesario
+    def create_cfdi_partner(self, xml_data, partner_type):
+        data = {
+            'name': xml_data['Comprobante'][partner_type]['@Nombre'],
+            'vat': xml_data['Comprobante'][partner_type]['@Rfc'],
+            'l10n_mx_edi_fiscal_regime': xml_data['Comprobante'][partner_type][
+                '@RegimenFiscal'] if partner_type == "Emisor" else xml_data['Comprobante'][partner_type][
+                '@RegimenFiscalReceptor'],
+            'country_id': self.env.company.country_id.id,
+            'company_type': 'company'
+        }
+        partner_id = self.env["res.partner"].create(data)
+        return partner_id
+
+    # --------------------------------------------------------------------------------------------------------------------
+
+
+    # ---------------------------------------------------Metodos de descarga masiva---------------------------------------------------------
+
+    def download_massive_pdf_zip(self):
+        return self.download_massive_zip("PDF Masivos.zip", "pdf_id")
+
+    def download_massive_xml_zip(self):
+        return self.download_massive_zip("XML Masivos.zip", "attachment_id")
+
+    def download_massive_zip(self, filename, field_name):
+        self = self.with_user(1)
+        zip_file = self.env['ir.attachment'].sudo().search([('name', '=', filename)], limit=1)
+        if zip_file:
+            zip_file.sudo().unlink()
+
+        # Funcion para decodificar el archivo
+        def isBase64_decodestring(s):
+            try:
+                decode_archive = base64.decodebytes(s)
+                return decode_archive
+            except Exception as e:
+                raise ValidationError('Error:', + str(e))
+
+        tempdir_file = TemporaryDirectory()
+        location_tempdir = tempdir_file.name
+        # Creando ruta dinamica para poder guardar el archivo zip
+        date_act = date.today()
+        file_name = 'DescargaMasiva(Fecha de descarga' + " - " + str(date_act) + ")"
+        file_name_zip = file_name + ".zip"
+        zipfilepath = os.path.join(location_tempdir, file_name_zip)
+        path_files = os.path.join(location_tempdir)
+
+        # Creando zip
+        for file in self.mapped(field_name):
+            object_name = file.name
+            ruta_ob = object_name
+            object_handle = open(os.path.join(location_tempdir, ruta_ob), "wb")
+            object_handle.write(isBase64_decodestring(file.datas))
+            object_handle.close()
+
+        with ZipFile(zipfilepath, 'w') as zip_obj:
+            for file in os.listdir(path_files):
+                file_path = os.path.join(path_files, file)
+                if file_path != zipfilepath:
+                    zip_obj.write(file_path, basename(file_path))
+
+        with open(zipfilepath, 'rb') as file_data:
+            bytes_content = file_data.read()
+            encoded = base64.b64encode(bytes_content)
+
+        data = {
+            'name': filename,
+            'type': 'binary',
+            'datas': encoded,
+            'company_id': self.env.company.id
+        }
+        attachment = self.env['ir.attachment'].sudo().create(data)
+        return self.download_zip(file_name_zip, attachment.id)
+
+    def download_zip(self, filename, id_file):
+        path = "/web/binary/download_document?"
+        model = "ir.attachment"
+
+        url = path + "model={}&id={}&filename={}".format(model, id_file, filename)
+        return {
+            'type': 'ir.actions.act_url',
+            'url': url,
+            'target': 'self',
+        }
+
+    # ---------------------------------------------------------------------------------------------------------------------
+
+    # ---------------------------------------------------Metodos de conciliación---------------------------------------------------------
+
+    def action_done(self):
+        self = self.with_user(1)
+        invoice_list = []
+        folio_names = []
+        for rec in self.filtered(lambda cfdi: not cfdi.move_id and cfdi.attachment_id and cfdi.cfdi_type in ["SI", "I","SE"]):
+            cfdi_type = rec.cfdi_type
+            partner_id = rec.emitter_id if cfdi_type in ["SI",'SE','SP'] else rec.receiver_id
+            folio = f"{rec.serie}-{rec.folio}" if rec.serie and rec.folio else f"{rec.serie}" if rec.serie and not rec.folio else f"{rec.folio}" if not rec.serie and rec.folio else ''
+            move_type = {
+                "SI": "in_invoice",
+                "I": "out_invoice",
+                "SE": "in_refund",
+                "E": "out_refund",
+            }
+            invoice_data = {
+                'move_type': move_type.get(cfdi_type),
+                'partner_id': partner_id.id,
+                'date': rec.date,
+                'invoice_date': rec.date,
+                'invoice_date_due': rec.date,
+                'fiscal_position_id': partner_id.property_account_position_id.id if partner_id.property_account_position_id else rec.fiscal_position_id.id,
+                'ref': folio,
+                'amount_total_signed': rec.total,
+                'amount_total': rec.total,
+                'journal_id': rec.journal_id.id,
+                'company_id': rec.company_id.id,
+                'cfdi_id': rec.id,
+                'x_uuid': rec.uuid,
+                "x_delivery_number": rec.delivery_number,
+                "x_invoice_qty": rec.invoice_qty
+            }
+            if folio != '' and not self.env["account.move"].sudo().search([("name", "=", folio), ("state", "=", "posted")], limit=1) and folio not in folio_names and cfdi_type == "I":
+                invoice_data["name"] = folio
+            folio_names.append(folio)
+            i = 1
+            line_list = []
+            for line in rec.concept_ids:
+                if float(line.amount) > 0:
+                    data_line = {
+                        'sequence': i,
+                        'name': line.description,
+                        'quantity': float(line.quantity),
+                        'product_uom_id': line.uom_id.id,
+                        'discount': (float(line.discount) * 100) / float(line.amount),
+                        'price_unit': float(line.unit_price),
+                        'tax_ids': line.mapped("tax_ids.tax_id").ids,
+                        'account_id': line.account_id.id if line.account_id else rec.account_id.id if rec.account_id else False,
+                        'analytic_distribution': line.analytic_distribution if line.analytic_distribution else rec.analytic_distribution,
+                        'partner_id': partner_id.id,
+                    }
+
+                    if line.product_tmpl_id:
+                        product_id = self.env['product.product'].sudo().search([('product_tmpl_id', '=', line.product_tmpl_id.id)], limit=1)
+                        data_line["product_id"] = product_id.id
+                    line_list.append(Command.create(data_line))
+                    i = i + 1
+            invoice_data["invoice_line_ids"] = line_list
+            invoice_list.append(invoice_data)
+        invoice_ids = self.env['account.move'].with_context(check_move_validity=False).sudo().create(invoice_list)
+
+        for invoice_id in invoice_ids:
+            attachment_id = invoice_id.cfdi_id.attachment_id
+            invoice_id.cfdi_id.write({
+                "move_id": invoice_id.id,
+                "state": "done"
+            })
+            attachment_id.write({
+                'res_model': 'account.move',
+                'res_id': invoice_id.id,
+            })
+            if invoice_id.move_type == "out_invoice":
+                if attachment_id:
+                    id_edi_format = self.env['account.edi.format'].sudo().search([('name', '=', 'CFDI (4.0)')], limit=1)
+                    if not invoice_id.edi_document_ids:
+                        if id_edi_format:
+                            create_edi = self.env['account.edi.document'].sudo().create(
+                                {'edi_format_id': id_edi_format.id, 'attachment_id': attachment_id.id, 'state': 'sent',
+                                 'move_id': invoice_id.id, 'error': False})
+                            invoice_id.write({'edi_document_ids': [(6, False, [create_edi.id])], 'l10n_mx_edi_cfdi_uuid': invoice_id.l10n_mx_edi_cfdi_uuid_cusom})
+                        # Se colocaria la factura como timbrada
+                        elif id_edi_format and invoice_id.edi_document_ids:
+                            edi_format_id = invoice_id.edi_document_ids.filtered(
+                                lambda edi: edi.edi_format_id.id == id_edi_format.id)
+                            if edi_format_id:
+                                edi_format_id["attachment_id"] = attachment_id.id
+                                edi_format_id["error"] = False
+                                edi_format_id["state"] = "sent"
+                            else:
+                                data = {
+                                    'move_id': invoice_id.id,
+                                    'attachment_id': attachment_id.id,
+                                    'state': 'sent',
+                                    'error': False,
+                                    'edi_format_id': id_edi_format.id
+                                }
+                                self.env["account.edi.document"].sudo().create([data])
+                            invoice_id.write({'l10n_mx_edi_cfdi_uuid': invoice_id.l10n_mx_edi_cfdi_uuid_cusom})
+
+            invoice_id.write({
+                "state": "posted"
+            })
+        return {
+            "name": _("Facturas"),
+            "view_mode": "list,form",
+            "res_model": "account.move",
+            "type": "ir.actions.act_window",
+            "target": "current",
+            "domain": [('id', 'in', invoice_ids.ids)]
+        }
+
+    def action_unlink_move(self):
+        for rec in self:
+            if rec.move_id:
+                rec.attachment_id.write({
+                    'res_model': 'account.cfdi',
+                    'res_id': rec.id,
+                })
+                rec.write({
+                    "state": "draft",
+                    "move_id": False
+                })
+                rec.move_id.write({
+                    'cfdi_id': False,
+                    'x_uuid': False
+                })

+ 56 - 0
custom_sat_connection/models/account_cfdi_line.py

@@ -0,0 +1,56 @@
+from odoo import api, fields, models
+
+class AccountCfdiLine(models.Model):
+    _name = 'account.cfdi.line'
+    _description = 'Linea de complemento CFDI'
+
+    sequence = fields.Integer(string='Secuencia')
+    name = fields.Char()
+    cfdi_id = fields.Many2one(comodel_name="account.cfdi", string="CFDI")
+    company_id = fields.Many2one(comodel_name='res.company', string='Empresa', related='cfdi_id.company_id')
+    code_cfdi = fields.Char(string='UUID')
+    date = fields.Date(string='Fecha')
+    folio = fields.Char(string='Folio')
+    payment_method = fields.Char(string='Forma de pago')
+    location = fields.Char(string='Lugar de expedición')
+    payment_type = fields.Selection(string='Método de pago', selection=[('PPD', 'PPD'), ('PUE', 'PUE')])
+    currency = fields.Char(string='Moneda')
+    certificate_number = fields.Char(string='Nro. de certificado')
+    stamp = fields.Char(string='Sello')
+    serie = fields.Char(string='Serie')
+    subtotal = fields.Float(string='Subtotal')
+    cfdi_type = fields.Selection(selection=[
+        ('I', u'Facturas de clientes'),
+        ('SI', u'Facturas de proveedor'),
+        ('E', u'Notas de crédito cliente'),
+        ('SE', u'Notas de crédito proveedor'),
+        ('P', u'REP de clientes'),
+        ('SP', u'REP de proveedores'),
+        ('N', u'Nóminas de empleados'),
+        ('SN', u'Nómina propia'),
+        ('T', u'Factura de traslado cliente'),
+        ('ST', u'Factura de traslado proveedor'),
+    ], string='Tipo de comprobante', index=True)
+    total = fields.Float(string='Total', readonly=True)
+    version = fields.Char(string='Versión', readonly=True)
+    emitter_id = fields.Many2one(comodel_name='res.partner', string='Emisor')
+    receiver_id = fields.Many2one(comodel_name='res.partner', string='Receptor')
+    quantity = fields.Float(string='Cantidad', digits='Product Unit of Measure')
+    product_code = fields.Char(string='Clave')
+    uom_code = fields.Char(string='Clave unidad')
+    description = fields.Char(string='Descripción')
+    discount = fields.Float(string='Descuento')
+    amount = fields.Float(string='Importe')
+    unit_price = fields.Float(string='Valor unitario', digits='Product Price')
+    no_identification = fields.Char(string='Identificación', readonly=True)
+    uom = fields.Char(string='Unidad', readonly=True)
+    uom_id = fields.Many2one(comodel_name='uom.uom', string='Unidad de medida')
+    unspsc_product_category_id = fields.Many2one(comodel_name='product.unspsc.code', string='Categoria')
+    product_tmpl_id = fields.Many2one(comodel_name='product.template', string='Plantilla del producto')
+    product_id = fields.Many2one(comodel_name='product.product', string='Producto')
+    account_id = fields.Many2one(comodel_name='account.account', string='Cuenta contable')
+    account_analytic_account_id = fields.Many2one(comodel_name='account.analytic.account', string='Cuenta analítica')
+    analytic_distribution = fields.Json(string="Distribución analítica")
+    analytic_precision = fields.Integer(string="Precisión analítica", store=False, default=lambda self: self.env['decimal.precision'].precision_get("Percentage Analytic"))
+    tax_ids = fields.One2many('account.cfdi.tax', 'concept_id', string='Impuestos')
+

+ 17 - 0
custom_sat_connection/models/account_cfdi_tax.py

@@ -0,0 +1,17 @@
+from odoo import api, fields, models
+
+class AccountCfdiTax(models.Model):
+    _name = 'account.cfdi.tax'
+    _description = 'Impuestos de conceptos de CFDI'
+
+    sequence = fields.Integer(string='Secuencia', required=True)
+    company_id = fields.Many2one(comodel_name='res.company', string='Empresa', related='concept_id.company_id', store=True)
+    base = fields.Float(string='Base')
+    code = fields.Char(string='Código')
+    factor_type = fields.Char(string='Código de porcentaje')
+    rate = fields.Float(string='Tasa o cuota', digits=(6, 4))
+    amount = fields.Float(string='Importe')
+    tax_id = fields.Many2one(comodel_name='account.tax', string='Impuesto')
+    concept_id = fields.Many2one(comodel_name='account.cfdi.line', string='Concepto de CFDI', ondelete="cascade")
+    cfdi_id = fields.Many2one(comodel_name='account.cfdi', string='CFDI', related="concept_id.cfdi_id", store=True, ondelete="cascade")
+    tax_type = fields.Selection(string="Tipo de impuesto", selection=[('retencion', 'Retención'), ('traslado', 'Traslados')])

+ 149 - 0
custom_sat_connection/models/account_esignature_certificate.py

@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import logging
+import ssl
+from datetime import datetime
+from cryptography.hazmat.primitives import serialization
+_logger = logging.getLogger(__name__)
+try:
+    from OpenSSL import crypto
+except ImportError:
+    _logger.warning('OpenSSL library not found. If you plan to use l10n_mx_edi, please install the library from https://pypi.python.org/pypi/pyOpenSSL')
+
+from pytz import timezone
+from odoo import _, api, fields, models, tools
+from odoo.exceptions import ValidationError, UserError
+from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
+
+def convert_key_cer_to_pem(key, password):
+    # TODO compute it from a python way
+    private_key = serialization.load_der_private_key(base64.b64decode(key), password.encode())
+    return private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.PKCS8,
+        encryption_algorithm=serialization.NoEncryption()
+    )
+
+def str_to_datetime(dt_str, tz=timezone('America/Mexico_City')):
+    return tz.localize(fields.Datetime.from_string(dt_str))
+
+class AccountEsignatureCertificate(models.Model):
+    _name = 'account.esignature.certificate'
+    _description = 'Certificado de México'
+
+    content = fields.Binary(string='Certificado', required=True)
+    key = fields.Binary(string='Llave de certificado',required=True)
+    password = fields.Char(string='Contraseña', required=True)
+    holder = fields.Char(string='Nombre', required=False)
+    holder_vat = fields.Char(string="RFC", required=False)
+    serial_number = fields.Char(string='Número de serie', readonly=True, index=True)
+    date_start = fields.Datetime(string='Fecha inicio', readonly=True)
+    date_end = fields.Datetime(string='Fecha final', readonly=True)
+
+    @tools.ormcache('content')
+    def get_pem_cer(self, content):
+        '''Get the current content in PEM format
+        '''
+        self.ensure_one()
+        return ssl.DER_cert_to_PEM_cert(base64.decodebytes(content)).encode('UTF-8')
+
+    @tools.ormcache('key', 'password')
+    def get_pem_key(self, key, password):
+        '''Get the current key in PEM format
+        '''
+        self.ensure_one()
+        private_key = serialization.load_der_private_key(base64.b64decode(key), password.encode())
+        return private_key.private_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PrivateFormat.PKCS8,
+            encryption_algorithm=serialization.NoEncryption()
+        )
+
+    def get_data(self):
+        '''Return the content (b64 encoded) and the certificate decrypted
+        '''
+        self.ensure_one()
+        cer_pem = self.get_pem_cer(self.content)
+        certificate = crypto.load_certificate(crypto.FILETYPE_PEM, cer_pem)
+        for to_del in ['\n', ssl.PEM_HEADER, ssl.PEM_FOOTER]:
+            cer_pem = cer_pem.replace(to_del.encode('UTF-8'), b'')
+        return cer_pem, certificate
+
+    def get_mx_current_datetime(self):
+        '''Get the current datetime with the Mexican timezone.
+        '''
+        mexican_tz = timezone('America/Mexico_City')
+        return datetime.now(mexican_tz)
+
+    def get_valid_certificate(self):
+        '''Search for a valid certificate that is available and not expired.
+        '''
+        mexican_dt = self.get_mx_current_datetime()
+        for record in self:
+            date_start = str_to_datetime(record.date_start)
+            date_end = str_to_datetime(record.date_end)
+            if date_start <= mexican_dt <= date_end:
+                return record
+        return None
+
+    def get_encrypted_cadena(self, cadena):
+        '''Encrypt the cadena using the private key.
+        '''
+        self.ensure_one()
+        key_pem = self.get_pem_key(self.key, self.password)
+        private_key = crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem)
+        encrypt = 'sha256WithRSAEncryption'
+        cadena_crypted = crypto.sign(private_key, cadena, encrypt)
+        return base64.b64encode(cadena_crypted)
+
+    @api.constrains('content', 'key', 'password')
+    def _check_credentials(self):
+        '''Check the validity of content/key/password and fill the fields
+        with the certificate values.
+        '''
+        mexican_tz = timezone('America/Mexico_City')
+        mexican_dt = self.get_mx_current_datetime()
+        date_format = '%Y%m%d%H%M%SZ'
+        for record in self:
+            try:
+                cer_pem, certificate = record.get_data()
+                before = mexican_tz.localize(
+                    datetime.strptime(certificate.get_notBefore().decode("utf-8"), date_format))
+                after = mexican_tz.localize(
+                    datetime.strptime(certificate.get_notAfter().decode("utf-8"), date_format))
+                serial_number = certificate.get_serial_number()
+                subject = certificate.get_subject()
+                holder = subject.CN
+            except Exception as e:
+                raise ValidationError(_('El contenido del certificado no es valido.'))
+
+            record.holder = holder
+            record.serial_number = ('%x' % serial_number)[1::2]
+            record.date_start = before.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+            record.date_end = after.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+            if mexican_dt > after:
+                raise ValidationError(_('El certificado expiro desde %s') % record.date_end)
+            try:
+                key_pem = self.get_pem_key(self.key, self.password)
+                crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem)
+            except Exception:
+                raise ValidationError(_('La contraseña no corresponde, favor de validar'))
+
+    @api.model
+    def create(self, data):
+        res = super(AccountEsignatureCertificate, self).create(data)
+        self.clear_caches()
+        return res
+
+    def write(self, data):
+        res = super(AccountEsignatureCertificate, self).write(data)
+        self.clear_caches()
+        return res
+
+    def unlink(self):
+        if self.env['account.move'].search([('l10n_mx_edi_cfdi_certificate_id', 'in', self.ids)]):
+            raise UserError(_('No es posible eliminar un certificado valido y vigente.'))
+        res = super(AccountEsignatureCertificate, self).unlink()
+        self.clear_caches()
+        return res

+ 17 - 0
custom_sat_connection/models/account_journal.py

@@ -0,0 +1,17 @@
+from odoo import api, fields, models
+
+class AccountJournal(models.Model):
+	_inherit = 'account.journal'
+
+	x_cfdi_type = fields.Selection(selection=[
+		('I', 'Facturas de clientes'),
+		('SI', 'Facturas de proveedor'),
+		('E', 'Notas de crédito cliente'),
+		('SE', 'Notas de crédito proveedor'),
+		('P', 'REP de clientes'),
+		('SP', 'REP de proveedores'),
+		('N', 'Nóminas de empleados'),
+		('SN', 'Nómina propia'),
+		('T', 'Factura de traslado cliente'),
+		('ST', 'Factura de traslado proveedor'),
+	], string='Tipo de comprobante')

+ 28 - 0
custom_sat_connection/models/account_move.py

@@ -0,0 +1,28 @@
+from odoo import api, fields, models
+
+class AccountMove(models.Model):
+    _inherit = "account.move"
+
+    cfdi_id = fields.Many2one(comodel_name="account.cfdi", string="CFDI")
+    x_uuid = fields.Char(string="CFDI UIID", related="cfdi_id.uuid", store=True)
+    x_delivery_number = fields.Char(string="No. Entrega")
+    x_invoice_qty = fields.Integer(string="Unidades facturadas")
+
+    #Eliminar relacion con el cfdi si se cancela el movimiento
+    def button_cancel(self):
+        res = super(AccountMove, self).button_cancel()
+        for rec in self:
+            if rec.cfdi_id:
+                rec.cfdi_id.attachment_id.write({
+                    'res_model': 'account.cfdi',
+                    'res_id': rec.cfdi_id.id,
+                })
+                rec.cfdi_id.write({
+                    "state": "draft",
+                    "move_id": False
+                })
+                rec.write({
+                    'cfdi_id': False,
+                    'x_uuid': False
+                })
+        return res

+ 882 - 0
custom_sat_connection/models/portal_sat.py

@@ -0,0 +1,882 @@
+# -*- coding: utf-8 -*-
+# !/usr/bin/env python
+
+import base64
+import calendar
+import datetime
+from copy import deepcopy
+from html.parser import HTMLParser
+from uuid import UUID
+from OpenSSL import crypto
+from requests import exceptions, adapters
+import httpx
+import urllib3
+import ssl
+
+urllib3.disable_warnings()
+import logging
+
+_logger = logging.getLogger(__name__)
+
+TIMEOUT = 120
+TRY_COUNT = 3
+VERIFY_CERT = True
+CONTEXT = ssl.create_default_context()
+CONTEXT.set_ciphers('HIGH:!DH:!aNULL')
+
+#LE DA FORMATO A LOS VALORES DEL HTML
+class FormValues(HTMLParser):
+    _description = 'Elementos del HTML'
+
+    def __init__(self):
+        super().__init__()
+        self.values = {}
+
+    def handle_starttag(self, tag, attrs):
+        if tag in ('input', 'select'):
+            a = dict(attrs)
+            if a.get('type', '') and a['type'] == 'hidden':
+                if 'name' in a and 'value' in a:
+                    self.values[a['name']] = a['value']
+
+#LE DA FORMATO A LOS VALORES DEL HTML DEL INICIO DE SESION
+class FormLoginValues(HTMLParser):
+    _description = 'Elementos del HTML del inicio de sesión'
+
+    def __init__(self):
+        super().__init__()
+        self.values = {}
+
+    def handle_starttag(self, tag, attrs):
+        if tag == 'input':
+            attrib = dict(attrs)
+            try:
+                self.values[attrib['id']] = attrib['value']
+            except:
+                pass
+
+class Filters(object):
+    _description = 'Filters'
+
+    def __init__(self, args):
+        self.date_from = args['date_from']
+        self.day = args.get('day', False)
+        self.emitidas = args['emitidas']
+        self.date_to = None
+        if self.date_from:
+            self.date_to = args.get('date_to', self._now()).replace(hour=23, minute=59, second=59, microsecond=0)
+        self.uuid = str(args.get('uuid', ''))
+        self.stop = False
+        self.hour = False
+        self.minute = False
+        self._init_values(args)
+
+    def __str__(self):
+        if self.uuid:
+            msg = 'Descargar por UUID'
+        elif self.hour:
+            msg = 'Descargar por HORA'
+        elif self.day:
+            msg = 'Descargar por DIA'
+        else:
+            msg = 'Descargar por MES'
+        tipo = 'Recibidas'
+        if self.emitidas:
+            tipo = 'Emitidas'
+        if self.uuid:
+            return '{} - {} - {}'.format(msg, self.uuid, tipo)
+        else:
+            return '{} - {} - {} - {}'.format(msg, self.date_from, self.date_to, tipo)
+
+    def _now(self):
+        if self.day:
+            n = self.date_from
+        else:
+            last_day = calendar.monthrange(
+                self.date_from.year, self.date_from.month)[1]
+            n = datetime.datetime(self.date_from.year, self.date_from.month, last_day)
+        return n
+
+    def _init_values(self, args):
+        status = '-1'
+        type_cfdi = args.get('type_cfdi', '-1')
+        center_filter = 'RdoFechas'
+        if self.uuid:
+            center_filter = 'RdoFolioFiscal'
+        rfc_receptor = args.get('rfc_emisor', False)
+        if self.emitidas:
+            rfc_receptor = args.get('rfc_receptor', False)
+        script_manager = 'ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$BtnBusqueda'
+        self._post = {
+            '__ASYNCPOST': 'true',
+            '__EVENTTARGET': '',
+            '__EVENTARGUMENT': '',
+            '__LASTFOCUS': '',
+            '__VIEWSTATEENCRYPTED': '',
+            'ctl00$ScriptManager1': script_manager,
+            'ctl00$MainContent$hfInicialBool': 'false',
+            'ctl00$MainContent$BtnBusqueda': 'Buscar CFDI',
+            'ctl00$MainContent$TxtUUID': self.uuid,
+            'ctl00$MainContent$FiltroCentral': center_filter,
+            'ctl00$MainContent$DdlEstadoComprobante': status,
+            'ctl00$MainContent$ddlComplementos': type_cfdi,
+        }
+        return
+
+    def get_post(self):
+        start_hour = '0'
+        start_minute = '0'
+        start_second = '0'
+        end_hour = '0'
+        end_minute = '0'
+        end_second = '0'
+        if self.date_from:
+            start_hour = str(self.date_from.hour)
+            start_minute = str(self.date_from.minute)
+            start_second = str(self.date_from.second)
+            end_hour = str(self.date_to.hour)
+            end_minute = str(self.date_to.minute)
+            end_second = str(self.date_to.second)
+        if self.emitidas:
+            year1 = '0'
+            year2 = '0'
+            start = ''
+            end = ''
+            if self.date_from:
+                year1 = str(self.date_from.year)
+                year2 = str(self.date_to.year)
+                start = self.date_from.strftime('%d/%m/%Y')
+                end = self.date_to.strftime('%d/%m/%Y')
+            data = {
+                'ctl00$MainContent$hfInicial': year1,
+                'ctl00$MainContent$CldFechaInicial2$Calendario_text': start,
+                'ctl00$MainContent$CldFechaInicial2$DdlHora': start_hour,
+                'ctl00$MainContent$CldFechaInicial2$DdlMinuto': start_minute,
+                'ctl00$MainContent$CldFechaInicial2$DdlSegundo': start_second,
+                'ctl00$MainContent$hfFinal': year2,
+                'ctl00$MainContent$CldFechaFinal2$Calendario_text': end,
+                'ctl00$MainContent$CldFechaFinal2$DdlHora': end_hour,
+                'ctl00$MainContent$CldFechaFinal2$DdlMinuto': end_minute,
+                'ctl00$MainContent$CldFechaFinal2$DdlSegundo': end_second,
+            }
+        else:
+            year = '0'
+            month = '0'
+            if self.date_from:
+                year = str(self.date_from.year)
+                month = str(self.date_from.month)
+            day = '00'
+            if self.day:
+                day = '{:02d}'.format(self.date_from.day)
+            data = {
+                'ctl00$MainContent$CldFecha$DdlAnio': year,
+                'ctl00$MainContent$CldFecha$DdlMes': month,
+                'ctl00$MainContent$CldFecha$DdlDia': day,
+                'ctl00$MainContent$CldFecha$DdlHora': start_hour,
+                'ctl00$MainContent$CldFecha$DdlMinuto': start_minute,
+                'ctl00$MainContent$CldFecha$DdlSegundo': start_second,
+                'ctl00$MainContent$CldFecha$DdlHoraFin': end_hour,
+                'ctl00$MainContent$CldFecha$DdlMinutoFin': end_minute,
+                'ctl00$MainContent$CldFecha$DdlSegundoFin': end_second,
+            }
+        self._post.update(data)
+        return self._post
+
+
+class Invoice(HTMLParser):
+    _description = 'Invoice'
+
+    START_PAGE = 'ContenedorDinamico'
+    URL = 'https://portalcfdi.facturaelectronica.sat.gob.mx/'
+    END_PAGE = 'ctl00_MainContent_pageNavPosition'
+    LIMIT_RECORDS = 'ctl00_MainContent_PnlLimiteRegistros'
+    NOT_RECORDS = 'ctl00_MainContent_PnlNoResultados'
+    TEMPLATE_DATE = '%Y-%m-%dT%H:%M:%S'
+
+    def __init__(self):
+        super().__init__()
+        self._is_div_page = False
+        self._col = 0
+        self._current_tag = ''
+        self._last_link = ''
+        self._last_link_pdf = ''
+        self._last_uuid = ''
+        self._last_status = ''
+        self._last_date_cfdi = ''
+        self._last_date_timbre = ''
+        self._last_pac = ''
+        self._last_total = ''
+        self._last_type = ''
+        self._last_date_cancel = ''
+        self._last_emisor_rfc = ''
+        self._last_emisor = ''
+        self._last_receptor_rfc = ''
+        self._last_receptor = ''
+        self.invoices = []
+        self.not_found = False
+        self.limit = False
+
+    def handle_starttag(self, tag, attrs):
+        self._current_tag = tag
+        if tag == 'div':
+            attrib = dict(attrs)
+            if 'id' in attrib and attrib['id'] == self.NOT_RECORDS \
+                    and 'inline' in attrib['style']:
+                self.not_found = True
+            elif 'id' in attrib and attrib['id'] == self.LIMIT_RECORDS:
+                self.limit = True
+            elif 'id' in attrib and attrib['id'] == self.START_PAGE:
+                self._is_div_page = True
+            elif 'id' in attrib and attrib['id'] == self.END_PAGE:
+                self._is_div_page = False
+        elif self._is_div_page and tag == 'td':
+            self._col += 1
+        elif tag == 'span':
+            attrib = dict(attrs)
+            if attrib.get('id', '') == 'BtnDescarga':
+                self._last_link = attrib['onclick'].split("'")[1]
+            if attrib.get('id', '') == 'BtnRI':
+                self._last_link_pdf = attrib['onclick'].split("'")[1]
+
+    def handle_endtag(self, tag):
+        if self._is_div_page and tag == 'tr':
+            if self._last_uuid:
+                url_xml = ''
+                if self._last_link:
+                    url_xml = '{}{}'.format(self.URL, self._last_link)
+                    self._last_link = ''
+                url_pdf = ''
+                if self._last_link_pdf:
+                    url_pdf = '{}{}{}'.format(self.URL, "RepresentacionImpresa.aspx?Datos=", self._last_link_pdf)
+
+                date_cancel = None
+                if self._last_date_cancel:
+                    date_cancel = datetime.datetime.strptime(
+                        self._last_date_cancel, self.TEMPLATE_DATE)
+                invoice = (self._last_uuid,
+                           {
+                               'url': url_xml,
+                               'acuse': url_pdf,
+                               'estatus': self._last_status,
+                               'date_cfdi': datetime.datetime.strptime(
+                                   self._last_date_cfdi, self.TEMPLATE_DATE),
+                               'date_timbre': datetime.datetime.strptime(
+                                   self._last_date_timbre, self.TEMPLATE_DATE),
+                               'date_cancel': date_cancel,
+                               'rfc_pac': self._last_pac,
+                               'total': float(self._last_total),
+                               'tipo': self._last_type,
+                               'emisor': self._last_emisor,
+                               'rfc_emisor': self._last_emisor_rfc,
+                               'receptor': self._last_receptor,
+                               'rfc_receptor': self._last_receptor_rfc,
+                           }
+                           )
+                self.invoices.append(invoice)
+            self._last_uuid = ''
+            self._last_status = ''
+            self._last_date_cancel = ''
+            self._last_emisor_rfc = ''
+            self._last_emisor = ''
+            self._last_receptor_rfc = ''
+            self._last_receptor = ''
+            self._last_date_cfdi = ''
+            self._last_date_timbre = ''
+            self._last_pac = ''
+            self._last_total = ''
+            self._last_type = ''
+            self._col = 0
+
+    def handle_data(self, data):
+        cv = data.strip()
+        if self._is_div_page and self._current_tag == 'span' and cv:
+            if self._col == 1:
+                try:
+                    UUID(cv)
+                    self._last_uuid = cv
+                except ValueError:
+                    pass
+            elif self._col == 2:
+                self._last_emisor_rfc = cv
+            elif self._col == 3:
+                self._last_emisor = cv
+            elif self._col == 4:
+                self._last_receptor_rfc = cv
+            elif self._col == 5:
+                self._last_receptor = cv
+            elif self._col == 6:
+                self._last_date_cfdi = cv
+            elif self._col == 7:
+                self._last_date_timbre = cv
+            elif self._col == 8:
+                self._last_pac = cv
+            elif self._col == 9:
+                self._last_total = cv.replace('$', '').replace(',', '')
+            elif self._col == 10:
+                self._last_type = cv.lower()
+            elif self._col == 12:
+                self._last_status = cv
+            elif self._col == 14:
+                self._last_date_cancel = cv
+
+
+# CONEXION Y OBTENCION DE ELEMENTOS DEL SAT
+class PortalSAT(object):
+    _description = 'Conexion al portal del SAT inicio de sesion y descarga'
+
+    # CONSTANTES PARA LA CONEXION
+    URL_MAIN = 'https://portal.facturaelectronica.sat.gob.mx/'
+    HOST = 'cfdiau.sat.gob.mx'
+    BROWSER = 'Mozilla/5.0 (X11; Linux x86_64; rv:55.0) Gecko/20100101 Firefox/55.0'
+    REFERER = 'https://cfdiau.sat.gob.mx/nidp/app/login?id=SATx509Custom&sid=0&option=credential&sid=0'
+    PORTAL = 'portalcfdi.facturaelectronica.sat.gob.mx'
+    URL_LOGIN = 'https://{}/nidp/app/login'.format(HOST)
+    URL_FORM = 'https://{}/nidp/app/login?sid=0&sid=0'.format(HOST)
+    URL_PORTAL = 'https://portalcfdi.facturaelectronica.sat.gob.mx/'
+    URL_CONTROL = 'https://cfdicontribuyentes.accesscontrol.windows.net/v2/wsfederation'
+    URL_CONSULTA = URL_PORTAL + 'Consulta.aspx'
+    URL_RECEPTOR = URL_PORTAL + 'ConsultaReceptor.aspx'
+    URL_EMISOR = URL_PORTAL + 'ConsultaEmisor.aspx'
+    URL_LOGOUT = URL_PORTAL + 'logout.aspx?salir=y'
+    DIR_EMITIDAS = 'emitidas'
+    DIR_RECIBIDAS = 'recibidas'
+    COMPANY_ID = ""
+
+    def __init__(self, rfc, target, sin):
+        self._rfc = rfc
+        self.error = ''
+        self.is_connect = False
+        self.not_network = False
+        self.only_search = False
+        self.only_test = False
+        self.sin_sub = sin
+        self._only_status = False
+        self._init_values(target)
+
+    def _init_values(self, target):
+        self._folder = target
+        self._emitidas = False
+        self._current_year = datetime.datetime.now().year
+
+        self._session = httpx.Client(http2=True, timeout=TIMEOUT, verify=CONTEXT)
+        a = adapters.HTTPAdapter(pool_connections=512, pool_maxsize=512, max_retries=5)
+        return
+
+    def _get_post_form_dates(self):
+        post = {}
+        post['__ASYNCPOST'] = 'true'
+        post['__EVENTARGUMENT'] = ''
+        post['__EVENTTARGET'] = 'ctl00$MainContent$RdoFechas'
+        post['__LASTFOCUS'] = ''
+        post['ctl00$MainContent$CldFecha$DdlAnio'] = str(self._current_year)
+        post['ctl00$MainContent$CldFecha$DdlDia'] = '0'
+        post['ctl00$MainContent$CldFecha$DdlHora'] = '0'
+        post['ctl00$MainContent$CldFecha$DdlHoraFin'] = '23'
+        post['ctl00$MainContent$CldFecha$DdlMes'] = '1'
+        post['ctl00$MainContent$CldFecha$DdlMinuto'] = '0'
+        post['ctl00$MainContent$CldFecha$DdlMinutoFin'] = '59'
+        post['ctl00$MainContent$CldFecha$DdlSegundo'] = '0'
+        post['ctl00$MainContent$CldFecha$DdlSegundoFin'] = '59'
+        post['ctl00$MainContent$DdlEstadoComprobante'] = '-1'
+        post['ctl00$MainContent$FiltroCentral'] = 'RdoFechas'
+        post['ctl00$MainContent$TxtRfcReceptor'] = ''
+        post['ctl00$MainContent$TxtUUID'] = ''
+        post['ctl00$MainContent$ddlComplementos'] = '-1'
+        post['ctl00$MainContent$hfInicialBool'] = 'true'
+        post['ctl00$ScriptManager1'] = 'ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$RdoFechas'
+        return post
+
+    #OBTENER RESPUESTAS DE LAS PETICIONES QUE SE REALIZAN AL SAT
+    def _response(self, url, method='get', headers={}, data={}):
+        try:
+            if method == 'get':
+                result = self._session.get(url, timeout=TIMEOUT)
+            else:
+                result = self._session.post(url, data=data, timeout=TIMEOUT)
+            msg = '{} {} {}'.format(result.status_code, method.upper(), url)
+            if result.status_code == 200:
+                return result.text
+            else:
+                _logger.error(msg)
+                return ''
+        except exceptions.Timeout:
+            msg = 'Tiempo de espera agotado'
+            self.not_network = True
+            _logger.error(msg)
+            return ''
+        except exceptions.ConnectionError:
+            msg = 'Revisa la conexión a Internet'
+            self.not_network = True
+            _logger.error(msg)
+            return ''
+
+    #LECTURA Y OBTENCION DE CIERTOS ELEMENTOS QUE SE PRESENTAN EN EL HTML
+    def _read_form(self, html, form=''):
+        if form == 'login':
+            parser = FormLoginValues()
+        else:
+            parser = FormValues()
+        parser.feed(html)
+        return parser.values
+
+    #OBTENCION DEL CABECERO QUE SE ENVIARA EN ALGUNAS PETICIONES
+    def _get_headers(self, host, referer, ajax=False):
+        acept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
+        headers = {
+            'Accept': acept,
+            'Accept-Encoding': 'gzip, deflate, br',
+            'Accept-Language': 'es-ES,es;q=0.5',
+            'Connection': 'keep-alive',
+            'DNT': '1',
+            'Host': host,
+            'Referer': referer,
+            'Upgrade-Insecure-Requests': '1',
+            'User-Agent': self.BROWSER,
+            'Content-Type': 'application/x-www-form-urlencoded',
+        }
+        if ajax:
+            headers.update({
+                'Cache-Control': 'no-cache',
+                'X-MicrosoftAjax': 'Delta=true',
+                'x-requested-with': 'XMLHttpRequest',
+                'Pragma': 'no-cache',
+            })
+        return headers
+
+    #OBTENER LOS VALORES DE LOS CAMPOS Y BOTONES DE LA PANTALLA DE CONSULTA
+    def _get_post_type_search(self, html):
+        tipo_busqueda = 'RdoTipoBusquedaReceptor'
+        if self._emitidas:
+            tipo_busqueda = 'RdoTipoBusquedaEmisor'
+        sm = 'ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$BtnBusqueda'
+        post = self._read_form(html)
+        post['ctl00$MainContent$TipoBusqueda'] = tipo_busqueda
+        post['__ASYNCPOST'] = 'true'
+        post['__EVENTTARGET'] = ''
+        post['__EVENTARGUMENT'] = ''
+        post['ctl00$ScriptManager1'] = sm
+        return post
+
+    #OBTENER INFORMACION DEL CERTIFICADO
+    def _get_data_cert(self, fiel_cert_data):
+        cert = crypto.load_certificate(crypto.FILETYPE_ASN1, fiel_cert_data)
+        rfc = cert.get_subject().x500UniqueIdentifier.split(' ')[0]
+        serie = '{0:x}'.format(cert.get_serial_number())[1::2]
+        fert = cert.get_notAfter().decode()[2:]
+        return rfc, serie, fert
+
+    #OBTENER UNA FIRMA PARA PODER OBTENER UN TOKEN MAS ADELANTE
+    def _sign(self, fiel_pem_data, data):
+        key = crypto.load_privatekey(crypto.FILETYPE_PEM, fiel_pem_data)
+        sign = base64.b64encode(crypto.sign(key, data, 'sha256'))
+        return base64.b64encode(sign).decode('utf-8')
+
+    #OBTENCION DEL TOKEN QUE NOS PERMITIRA MANTENER LA SESION INICIADA
+    def _get_token(self, firma, co):
+        co = base64.b64encode(co.encode('utf-8')).decode('utf-8')
+        data = '{}#{}'.format(co, firma).encode('utf-8')
+        token = base64.b64encode(data).decode('utf-8')
+        return token
+
+    #OBTENCION DE LA INFORMACION QUE SE ENVIARA AL SAT PARA EL INICIO DE SESION
+    def _make_data_form(self, fiel_cert_data, fiel_pem_data, values):
+        rfc, serie, fert = self._get_data_cert(fiel_cert_data)
+        co = '{}|{}|{}'.format(values['tokenuuid'], rfc, serie)
+        firma = self._sign(fiel_pem_data, co)
+        token = self._get_token(firma, co)
+        keys = ('credentialsRequired', 'guid', 'ks', 'urlApplet')
+        data = {k: values[k] for k in keys}
+        data['fert'] = fert
+        data['token'] = token
+        data['arc'] = ''
+        data['placer'] = ''
+        data['secuence'] = ''
+        data['seeder'] = ''
+        data['tan'] = ''
+        return data
+
+    # CONEXION CON EL PORTAL DEL SAT
+    def login_fiel(self, fiel_cert_data, fiel_pem_data, certificate, company_id):
+        # CREAMOS SESION PERSISTENTE
+        client = self._session
+        # MANDAMOS LA SOLICITUD DE OBTENCION DEL SITIO WEB https://portal.facturaelectronica.sat.gob.mx/ PARA OBTENER REDIRECCIONAMIENTO
+        response = client.get(url=self.URL_MAIN)
+        # PETICION AL LOGIN CON FIEL
+        headers = {
+            "referer": self.get_url(response.url),
+        }
+        response = client.post(url="https://cfdiau.sat.gob.mx/nidp/wsfed/ep?id=SATUPCFDiCon&sid=0&option=credential&sid=0", headers=headers)
+        # PETICION PARA OBTENER EL FORMULARIO
+        headers["referer"] = self.get_url(response.url)
+        response = client.get(url="https://cfdiau.sat.gob.mx/nidp/app/login?id=SATx509Custom&sid=0&option=credential&sid=0", headers=headers)
+        values = self._read_form(response.text, 'login')
+        data = self._make_data_form(fiel_cert_data, fiel_pem_data, values)
+        headers["referer"] = self.get_url(response.url)
+        headers.update(self._get_headers(self.HOST, self.get_url(response.url)))
+        headers = {
+            "cache-control": "max-age=0",
+            "origin": "https://cfdiau.sat.gob.mx",
+            "content-type": "application/x-www-form-urlencoded",
+            "upgrade-insecure-requests": "1",
+            "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:55.0) Gecko/20100101 Firefox/55.0",
+            "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
+            "sec-gpc": "1",
+            "accept-language": "es-ES,es;q=0.5",
+            "sec-fetch-site": "same-origin",
+            "sec-fetch-dest": "document",
+            "referer": "https://cfdiau.sat.gob.mx/nidp/app/login?id=SATx509Custom&sid=0&option=credential&sid=0",
+            "accept-encoding": "gzip, deflate, br, zstd",
+            "priority": "u=0, i",
+        }
+        #NOS IDENTIFICAMOS EN EL SAT PARA PODER SEGUIR CON PETICIONES Y CONSULTAS
+        response = client.post(url="https://cfdiau.sat.gob.mx/nidp/app/login?id=SATx509Custom&sid=0&option=credential&sid=0", headers=headers, data=data)
+        headers["referer"] = "https://portal.facturaelectronica.sat.gob.mx/"
+        headers["Host"] = self.HOST
+        #SE OBTIENE LA PAGINA DE CONSULTA PARA OBTENER DATOS NECESARIOS PARA SU POSTERIOR USO EN LAS BUSQUEDAS
+        response = client.get(url=self.URL_CONSULTA)
+        data = self._read_form(response.text)
+        #SE MANDA LA INFORMACION PARA PODER SER REDIRIGIDOS CORRECTAMENTE A LA PAGINA DE CONSULTA
+        response = client.post(url=self.URL_CONSULTA, data=data)
+        self._session.headers.update(headers=headers)
+        self.is_connect = True
+        return True
+
+    def get_url(self, url_object):
+        url = f"{url_object.scheme}/{url_object.host}{url_object.full_path}"
+        return url
+
+    def _merge(self, list1, list2):
+        result = list1.copy()
+        result.update(list2)
+        return result
+
+    def _last_day(self, date):
+        last_day = calendar.monthrange(date.year, date.month)[1]
+        return datetime.datetime(date.year, date.month, last_day)
+
+    def _get_dates(self, d1, d2):
+        end = d2
+        dates = []
+        while True:
+            d2 = self._last_day(d1)
+            if d2 >= end:
+                dates.append((d1, end))
+                break
+            dates.append((d1, d2))
+            d1 = d2 + datetime.timedelta(days=1)
+        return dates
+
+    def _get_dates_recibidas(self, d1, d2):
+        days = (d2 - d1).days + 1
+        return [d1 + datetime.timedelta(days=d) for d in range(days)]
+
+    def _time_delta(self, days):
+        now = datetime.datetime.now()
+        date_from = now.replace(
+            hour=0, minute=0, second=0, microsecond=0) - datetime.timedelta(days=days)
+        date_to = now.replace(hour=23, minute=59, second=59, microsecond=0)
+        return date_from, date_to
+
+    def _time_delta_recibidas(self, days):
+        now = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+        return [now - datetime.timedelta(days=d) for d in range(days)]
+
+    #FILTROS PARA LA CONSULTA
+    def _get_filters(self, args, emitidas=True):
+        filters = []
+        data = {}
+        data['day'] = bool(args['dia'])
+        data['uuid'] = ''
+        if args['uuid']:
+            data['uuid'] = str(args['uuid'])
+        data['emitidas'] = emitidas
+        data['rfc_emisor'] = args.get('rfc_emisor', '')
+        data['rfc_receptor'] = args.get('rfc_receptor', '')
+        data['type_cfdi'] = args.get('tipo_complemento', '-1')
+
+        if args['fecha_inicial'] and args['fecha_final'] and emitidas:
+            dates = self._get_dates(args['fecha_inicial'], args['fecha_final'])
+            for start, end in dates:
+                data['date_from'] = start
+                data['date_to'] = end
+                filters.append(Filters(data))
+        elif args['fecha_inicial'] and args['fecha_final']:
+            dates = self._get_dates_recibidas(args['fecha_inicial'], args['fecha_final'])
+            is_first_date = False
+            for d in dates:
+                if not is_first_date:
+                    data['date_from'] = d
+                    is_first_date = True
+                else:
+                    d = d.replace(hour=0, minute=0, second=0, microsecond=0)
+                    data['date_from'] = d
+                data['day'] = True
+                filters.append(Filters(data))
+        elif args['intervalo_dias'] and emitidas:
+            data['date_from'], data['date_to'] = self._time_delta(args['intervalo_dias'])
+            filters.append(Filters(data))
+        elif args['intervalo_dias']:
+            dates = self._time_delta_recibidas(args['intervalo_dias'])
+            for d in dates:
+                data['date_from'] = d
+                data['day'] = True
+                filters.append(Filters(data))
+        elif args['uuid']:
+            data['date_from'] = None
+            filters.append(Filters(data))
+        else:
+            day = args['dia'] or 1
+            data['date_from'] = datetime.datetime(args['ano'], args['mes'], day)
+            filters.append(Filters(data))
+
+        return tuple(filters)
+
+    def _segment_filter(self, filters):
+        new_filters = []
+        if filters.stop:
+            return new_filters
+        date = filters.date_from
+        date_to = filters.date_to
+
+        if filters.minute:
+            for m in range(10):
+                nf = deepcopy(filters)
+                nf.stop = True
+                nf.date_from = date + datetime.timedelta(minutes=m)
+                nf.date_to = date + datetime.timedelta(minutes=m + 1)
+                new_filters.append(nf)
+        elif filters.hour:
+            minutes = tuple(range(0, 60, 10)) + (0,)
+            minutes = tuple(zip(minutes, minutes[1:]))
+            for m in minutes:
+                nf = deepcopy(filters)
+                nf.minute = True
+                nf.date_from = date + datetime.timedelta(minutes=m[0])
+                nf.date_to = date + datetime.timedelta(minutes=m[1])
+                if m[0] == 50 and nf.date_to.hour == 23:
+                    nf.date_to = nf.date_to.replace(
+                        hour=nf.date_to.hour, minute=59, second=59)
+                elif m[0] == 50 and nf.date_to.hour != 23:
+                    nf.date_to = nf.date_to.replace(
+                        hour=nf.date_to.hour + 1, minute=0, second=0)
+                new_filters.append(nf)
+        elif filters.day:
+            hours = tuple(range(0, 25))
+            hours = tuple(zip(hours, hours[1:]))
+            for h in hours:
+                nf = deepcopy(filters)
+                nf.hour = True
+                nf.date_from = date + datetime.timedelta(hours=h[0])
+                nf.date_to = date + datetime.timedelta(hours=h[1])
+                if h[1] == 24:
+                    nf.date_to = nf.date_from.replace(
+                        minute=59, second=59, microsecond=0)
+                new_filters.append(nf)
+        else:
+            last_day = calendar.monthrange(date.year, date.month)[1]
+            for d in range(last_day):
+                nf = deepcopy(filters)
+                nf.day = True
+                nf.date_from = date + datetime.timedelta(days=d)
+                nf.date_to = nf.date_from.replace(
+                    hour=23, minute=59, second=59, microsecond=0)
+                new_filters.append(nf)
+                if date_to == nf.date_to:
+                    break
+        return new_filters
+
+    #OBTENER INFORMACION QUE SE ENVIARA EN LA CONSULTA PARA OBTENER LOS CFDIS
+    def _get_post(self, html):
+        validos = ('EVENTTARGET', '__EVENTARGUMENT', '__LASTFOCUS', '__VIEWSTATE')
+        values = html.split('|')
+        post = {v: values[i + 1] for i, v in enumerate(values) if v in validos}
+        return post
+
+    #ASIGNAR UN HEADER PARA LA CONSULTA DE CFDIS
+    def _set_search_headers(self):
+        self._session.headers = {
+            "cache-control": "no-cache",
+            "x-requested-with": "XMLHttpRequest",
+            "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:55.0) Gecko/20100101 Firefox/55.0",
+            "x-microsoftajax": "Delta=true",
+            "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
+            "accept": "*/*",
+            "sec-gpc": "1",
+            "accept-language": "es-ES,es;q=0.5",
+            "origin": "https://portalcfdi.facturaelectronica.sat.gob.mx",
+            "sec-fetch-site": "same-origin",
+            "sec-fetch-mode": "cors",
+            "sec-fetch-dest": "empty",
+            "referer": "https://portalcfdi.facturaelectronica.sat.gob.mx/ConsultaEmisor.aspx",
+            "accept-encoding": "gzip, deflate, br, zstd",
+            "priority": "u=1, i",
+        }
+        return True
+
+    #OBTENCION DE LA PAGINA DE CONSULTA POR FECHAS
+    def _change_to_date(self, url_search):
+        client = self._session
+        self._set_search_headers()
+        response = client.get(url_search)
+        data = self._read_form(response.text)
+        post = self._merge(data, self._get_post_form_dates())
+        headers = self._get_headers(self.PORTAL, url_search, True)
+        response = client.post(url=url_search, headers=headers, data=post)
+        post = self._get_post(response.text)
+        return data, post
+
+    #BUSQUEDA DE CFDIS RECIBIDOS CREADOS POR UN PROVEEDOR
+    def _search_recibidas(self, filters):
+        url_search = self.URL_RECEPTOR
+        values, post_source = self._change_to_date(url_search)
+        invoice_content = {}
+        for f in filters:
+            post = self._merge(values, f.get_post())
+            post = self._merge(post, post_source)
+            headers = self._get_headers(self.PORTAL, url_search, True)
+            html = self._response(url_search, 'post', headers, post)
+            not_found, limit, invoices = self._get_download_links(html)
+            if not_found or not invoices:
+                msg = '\n\tNo se encontraron documentos en el filtro:\n\t{}'.format(str(f))
+                _logger.info(msg)
+            else:
+                data = self._download(invoices, limit, f)
+                if data and type(data) == dict:
+                    invoice_content.update(data)
+        return invoice_content
+
+    #BUSQUEDA DE CFDIS EMITIDOS CREADOS POR LA EMPRESA
+    def _search_emitidas(self, filters):
+        url_search = self.URL_EMISOR
+        values, post_source = self._change_to_date(url_search)
+        invoice_content = {}
+        for f in filters:
+            _logger.info(str(f))
+            post = self._merge(values, f.get_post())
+            post = self._merge(post, post_source)
+            headers = self._get_headers(self.PORTAL, url_search, True)
+            html = self._response(url_search, 'post', headers, post)
+            not_found, limit, invoices = self._get_download_links(html)
+            if not_found or not invoices:
+                msg = '\n\tNo se encontraron documentos en el filtro:\n\t{}'.format(str(f))
+                _logger.info(msg)
+            else:
+                data = self._download(invoices, limit, f, self.DIR_EMITIDAS)
+                if data and type(data) == dict:
+                    invoice_content.update(data)
+        return invoice_content
+
+    #PROCESO DE BUSQUEDA DE CFDIS PARA SU DESCARGA
+    def search(self, opt, download_option='both'):
+        self._only_status = opt['estatus']
+        invoice_content_e, invoice_content_r = {}, {}
+        if download_option == 'both':
+            filters_e = self._get_filters(opt, True)
+            invoice_content_e = self._search_emitidas(filters_e)
+            filters_r = self._get_filters(opt, False)
+            invoice_content_r = self._search_recibidas(filters_r)
+        elif download_option == 'supplier':
+            filters_r = self._get_filters(opt, False)
+            invoice_content_r = self._search_recibidas(filters_r)
+        elif download_option == 'customer':
+            filters_e = self._get_filters(opt, True)
+            invoice_content_e = self._search_emitidas(filters_e)
+        return invoice_content_r, invoice_content_e
+
+    #PROCESO DE DESCARGA
+    def _download(self, invoices, limit=False, filters=None, folder=DIR_RECIBIDAS):
+        if not invoices and not limit:
+            msg = '\n\tTodos los documentos han sido previamente descargados para el filtro.\n\t{}'.format(str(filters))
+            _logger.info(msg)
+            return {}
+
+        invoices_content = {}
+        if invoices and not self.only_search:
+            invoices_content = self._thread_download(invoices, folder, filters)
+        if limit:
+            sf = self._segment_filter(filters)
+            if folder == self.DIR_RECIBIDAS:
+                data = self._search_recibidas(sf)
+                if data and type(data) == dict:
+                    invoices_content.update(data)
+            else:
+                data = self._search_emitidas(sf)
+                if data and type(data) == dict:
+                    invoices_content.update(data)
+        return invoices_content
+
+    #OBTENCION DE LOS VALORE DE LA PETICION PARA OBTENER LAS URL DE LOS CFDI
+    def _thread_download(self, invoices, folder, filters):
+        for_download = invoices[:]
+        current = 1
+        total = len(for_download)
+        invoice_content = {}
+        for i in range(TRY_COUNT):
+            for uuid, values in for_download:
+                data = {
+                    'url': values['url'],
+                    'acuse': values['acuse'],
+                }
+                content = self._get_xml(uuid, data, current, total)
+                pdf_content = self._get_pdf(uuid, data, current, total)
+                if content:
+                    invoice_content.update({uuid: [values, content, pdf_content]})
+                current += 1
+
+            if len(invoice_content) == len(for_download):
+                break
+        if total:
+            msg = '{} documentos por descargar en: {}'.format(total, str(filters))
+            _logger.info(msg)
+        return invoice_content
+
+    #OBTENER EL DOCUMENTO XML POR MEDIO DE LA URL DE DESCARGAR
+    def _get_xml(self, uuid, values, current, count):
+        for i in range(TRY_COUNT):
+            try:
+                r = self._session.get(values['url'], timeout=TIMEOUT)
+                if r.status_code == 200:
+                    return r.content
+
+            except exceptions.Timeout:
+                _logger.debug('Tiempo de espera sobrepasado')
+                continue
+            except Exception as e:
+                _logger.error(str(e))
+                return
+        msg = 'Tiempo de espera agotado para el documento: {}'.format(uuid)
+        _logger.error(msg)
+        return
+
+        # OBTENER EL DOCUMENTO XML POR MEDIO DE LA URL DE DESCARGAR
+    def _get_pdf(self, uuid, values, current, count):
+        for i in range(TRY_COUNT):
+            try:
+                r = self._session.get(values['acuse'], timeout=TIMEOUT)
+                if r.status_code == 200:
+                    return r.content
+
+            except exceptions.Timeout:
+                _logger.debug('Tiempo de espera sobrepasado')
+                continue
+            except Exception as e:
+                _logger.error(str(e))
+                return
+        msg = 'Tiempo de espera agotado para el documento: {}'.format(uuid)
+        _logger.error(msg)
+        return
+
+    def _get_download_links(self, html):
+        parser = Invoice()
+        parser.feed(html)
+        return parser.not_found, parser.limit, parser.invoices
+
+    def logout(self):
+        msg = 'Cerrando sessión en el SAT'
+        _logger.debug(msg)
+        response = self._response(self.URL_LOGOUT)
+        self.is_connect = False
+        self._session.close()
+        msg = 'Sesión cerrada en el SAT'
+        _logger.info(msg)
+        return

+ 159 - 0
custom_sat_connection/models/res_company.py

@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, api, fields, _
+from odoo.exceptions import ValidationError, UserError
+import base64
+import time
+import logging
+from datetime import date, datetime
+from dateutil.relativedelta import relativedelta
+from .account_esignature_certificate import convert_key_cer_to_pem
+from .portal_sat import PortalSAT
+
+_logger = logging.getLogger(__name__)
+TRY_COUNT = 3  # INTENTOS DE CONEXION
+
+class ResCompany(models.Model):
+    _inherit = 'res.company'
+
+    x_esignature_ids = fields.Many2many(comodel_name='account.esignature.certificate', string='Certificado FIEL')
+    x_last_cfdi_fetch_date = fields.Datetime("Última sincronización")
+    x_only_supplier_cfdi = fields.Boolean("Solo documentos de proveedor")
+
+    @api.model
+    def auto_import_cfdi_invoices(self):
+        for company in self.search([('x_esignature_ids', '!=', False)]):
+            company.with_company(company.id).download_cfdi_invoices_sat()
+        return True
+
+    # =================================================PORTAL SAT===========================================================
+
+    # DESCARGAR LOS XML DESDE EL SAT
+    def download_cfdi_invoices_sat(self, start_date=False, end_Date=False, document_type=False):
+        esignature_ids = self.x_esignature_ids
+        esignature = esignature_ids.with_user(self.env.user).get_valid_certificate()
+        if not esignature:
+            raise ValidationError("No se encontraron certificados validos, favor de revisar.")
+
+        if not esignature.content or not esignature.key or not esignature.password:
+            raise ValidationError("Seleccine los archivos FIEL .cer o FIEL .pem.")
+
+        fiel_cert_data = base64.b64decode(esignature.content)
+        fiel_pem_data = convert_key_cer_to_pem(esignature.key, esignature.password)
+
+        opt = {'credenciales': None, 'rfc': None, 'uuid': None, 'ano': None, 'mes': None, 'dia': 0,
+               'intervalo_dias': None, 'fecha_inicial': None, 'fecha_final': None, 'tipo': 't',
+               'tipo_complemento': '-1', 'rfc_emisor': None, 'rfc_receptor': None, 'sin_descargar': False,
+               'base_datos': False, 'directorio_fiel': '', 'archivo_uuids': '', 'estatus': False}
+        today = datetime.utcnow()
+        if start_date and end_Date:
+            opt['fecha_inicial'] = datetime.combine(start_date, datetime.min.time())
+            opt['fecha_final'] = datetime.combine(end_Date, datetime.max.time())
+        elif self.x_last_cfdi_fetch_date:
+            last_import_date = self.x_last_cfdi_fetch_date
+            last_import_date - relativedelta(days=2)
+
+            fecha_inicial = last_import_date - relativedelta(days=2)
+            fecha_final = today + relativedelta(days=2)
+            opt['fecha_inicial'] = fecha_inicial
+            opt['fecha_final'] = fecha_final
+        else:
+            year = today.year
+            month = today.month
+            opt['ano'] = year
+            opt['mes'] = month
+
+        sat = False
+        for i in range(TRY_COUNT):
+            sat = PortalSAT(opt['rfc'], 'cfdi-descarga', False)
+            if sat.login_fiel(fiel_cert_data, fiel_pem_data, esignature, self.env.company):
+                time.sleep(1)
+                break
+        invoice_content_receptor, invoice_content_emisor = {}, {}
+        if sat and sat.is_connect:
+            if document_type == "supplier":
+                invoice_content_receptor, invoice_content_emisor = sat.search(opt, 'supplier')
+            elif document_type == "customer":
+                invoice_content_receptor, invoice_content_emisor = sat.search(opt, 'customer')
+            else:
+                invoice_content_receptor, invoice_content_emisor = sat.search(opt)
+            sat.logout()
+        elif sat:
+            sat.logout()
+
+        attachment_data = []
+        if invoice_content_receptor:
+            attachment_data += self.get_cfdi_data(invoice_content_receptor, attachment_data)
+        if invoice_content_emisor:
+            attachment_data += self.get_cfdi_data(invoice_content_emisor, attachment_data)
+        if attachment_data:
+            cfdi_ids = self.env['account.cfdi'].create_cfdis(attachment_data=attachment_data)
+            if cfdi_ids:
+                self.write({'x_last_cfdi_fetch_date': date.today()})
+                return {
+                    "name": _("CFDIs importados"),
+                    "view_mode": "list,form",
+                    "res_model": "account.cfdi",
+                    "type": "ir.actions.act_window",
+                    "target": "current",
+                    "domain": [("id", "in", cfdi_ids.ids)]
+                }
+            else:
+                return {
+                    'type': 'ir.actions.client',
+                    'tag': 'display_notification',
+                    'params': {
+                        'title': _(
+                            "No se cargaron nuevos CFDIs al sistema ya que estos ya existen o no se encontraron, favor de validar."),
+                        'type': 'warning',
+                        'sticky': True,
+                    },
+                }
+        else:
+            return {
+                'type': 'ir.actions.client',
+                'tag': 'display_notification',
+                'params': {
+                    'title': _(
+                        "No se encontraron CFDIs que coincidan con las fechas o estos ya se encuentran en el sistema, favor de validar."),
+                    'type': 'warning',
+                    'sticky': True,
+                },
+            }
+
+    # ======================================================================================================================
+
+    def get_cfdi_data(self, content_sat, attachment_data):
+        uuids = list(content_sat.keys())
+        cfdi_ids = self.env["account.cfdi"].sudo().search([('uuid', 'in', uuids)])
+        exist_uuids = cfdi_ids.mapped('uuid')
+
+        for uuid, data in content_sat.items():
+            if uuid in exist_uuids:
+                continue
+            xml_content = data[1]
+            pdf_content = data[2]
+            filename = uuid + ".xml"
+            filepdf = uuid + ".pdf"
+            data = dict(
+                name=filename,
+                store_fname=filename,
+                type='binary',
+                datas=base64.b64encode(xml_content),
+                company_id=self.id,
+                mimetype='application/xml'
+            )
+            data_pdf = dict(
+                name=filepdf,
+                store_fname=filepdf,
+                type='binary',
+                datas=base64.b64encode(pdf_content) if pdf_content else False,
+                company_id=self.id,
+                mimetype='application/pdf'
+            )
+            data_uuid = {
+                "xml": data,
+                "pdf": data_pdf
+            }
+            attachment_data.append(data_uuid)
+        return attachment_data

+ 7 - 0
custom_sat_connection/models/res_config_settings.py

@@ -0,0 +1,7 @@
+from odoo import api, fields, models
+
+class ResConfigSettings(models.TransientModel):
+    _inherit = "res.config.settings"
+
+    x_esignature_ids = fields.Many2many(related='company_id.x_esignature_ids', string='Certificados México', readonly=False)
+

+ 12 - 0
custom_sat_connection/models/res_partner.py

@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api
+
+
+class Respartner(models.Model):
+    _inherit = 'res.partner'
+
+    x_account_expense_id = fields.Many2one(comodel_name='account.account', string='Cuenta contable gasto')
+    x_product_tmpl_id = fields.Many2one(comodel_name='product.template', string='Producto')
+    x_tax_isr_id = fields.Many2one(comodel_name='account.tax', string='Retención ISR')
+    x_tax_iva_id = fields.Many2one(comodel_name='account.tax', string='Retención IVA')

+ 19 - 0
custom_sat_connection/security/ir.model.access.csv

@@ -0,0 +1,19 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_cfdi_user,account_cfdi_user,model_account_cfdi,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_manager,account_cfdi_manager,model_account_cfdi,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_cfdi_line_user,account_cfdi_line_user,model_account_cfdi_line,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_line_manager,account_cfdi_line_manager,model_account_cfdi_line,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_cfdi_tax_user,account_cfdi_tax_user,model_account_cfdi_tax,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_tax_manager,account_cfdi_tax_manager,model_account_cfdi_tax,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_cfdi_zip_user,account_cfdi_zip_user,model_account_cfdi_zip,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_zip_manager,account_cfdi_zip_manager,model_account_cfdi_zip,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_cfdi_xml_user,account_cfdi_xml_user,model_account_cfdi_xml,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_xml_manager,account_cfdi_xml_manager,model_account_cfdi_xml,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_cfdi_account_user,account_cfdi_account_user,model_account_cfdi,account.group_account_user,1,0,0,0
+access_account_cfdi_account_manager,iia_boveda_fiscal_cfdi,model_account_cfdi,account.group_account_manager,1,0,0,0
+access_account_cfdi_account_other_user,iia_boveda_fiscal_cfdi,model_account_cfdi,account.group_account_invoice,1,0,0,0
+access_account_cfdi_sat_user,account_cfdi_sat_user,model_account_cfdi_sat,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_cfdi_sat_manager,account_cfdi_sat_manager,model_account_cfdi_sat,custom_sat_connection.manager_account_cfdi,1,1,1,1
+access_account_esignature_certificate_user,access_account_esignature_certificate_user,model_account_esignature_certificate,custom_sat_connection.user_account_cfdi,1,1,1,0
+access_account_esignature_certificate_manager,account_esignature_certificate_manager,model_account_esignature_certificate,custom_sat_connection.manager_account_cfdi,1,1,1,1
+

+ 28 - 0
custom_sat_connection/security/res_groups.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data noupdate="1">
+
+        <record id="category_account_cfdi" model="ir.module.category">
+            <field name="name">Complementos SAT</field>
+            <field name="sequence">1000</field>
+        </record>
+
+        <record id="user_type_account_cfdi" model="ir.module.category">
+            <field name="name">Complementos SAT</field>
+            <field name="sequence">10</field>
+            <field name="parent_id" ref="custom_sat_connection.category_account_cfdi"/>
+        </record>
+
+        <record id="user_account_cfdi" model="res.groups">
+            <field name="name">Usuario complementos SAT</field>
+            <field name="category_id" ref="custom_sat_connection.user_type_account_cfdi"/>
+        </record>
+
+        <record id="manager_account_cfdi" model="res.groups">
+            <field name="name">Administrador complementos SAT</field>
+            <field name="category_id" ref="custom_sat_connection.user_type_account_cfdi"/>
+            <field name="implied_ids" eval="[(6, 0, [ref('custom_sat_connection.user_account_cfdi')])]"/>
+        </record>
+
+    </data>
+</odoo>

+ 25 - 0
custom_sat_connection/views/account_account.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="sat_connection_account_account_form" model="ir.ui.view">
+            <field name="name">sat_connection_account_account_form</field>
+            <field name="model">account.account</field>
+            <field name="inherit_id" ref="account.view_account_form"/>
+            <field name="arch" type="xml">
+
+                <xpath expr="//page[@name='accounting']" position="after">
+                    <page name="SAT" string="SAT">
+                        <group>
+                            <group>
+                                <field name="x_cfdi_type"/>
+                            </group>
+                        </group>
+                    </page>
+                </xpath>
+
+            </field>
+        </record>
+
+    </data>
+</odoo>

+ 227 - 0
custom_sat_connection/views/account_cfdi.xml

@@ -0,0 +1,227 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="account_cfdi_view_tree" model="ir.ui.view">
+            <field name="name">account_cfdi_view_tree</field>
+            <field name="model">account.cfdi</field>
+            <field name="arch" type="xml">
+                <list string="Complementos CFDI">
+                    <header>
+                        <button name="action_done" string="Procesar" type="object" class="btn btn-primary"/>
+                        <button name="download_massive_xml_zip" string="Descargar XML" type="object"
+                                class="btn btn-secondary"/>
+                        <button name="download_massive_pdf_zip" string="Descargar PDF" type="object"
+                                class="btn btn-secondary"/>
+                    </header>
+                    <field name="name"/>
+                    <field name="date"/>
+                    <field name="emitter_id"/>
+                    <field name="receiver_id"/>
+                    <field name="uuid"/>
+                    <field name="serie"/>
+                    <field name="folio"/>
+                    <field name="move_id"/>
+                    <field name="cfdi_type"/>
+                    <field name="subtotal"/>
+                    <field name="total"/>
+                    <field name="state" widget="badge" decoration-success="state == 'done'"
+                           decoration-muted="state == 'cancel'" decoration-info="state == 'draft'"/>
+                    <field name="sat_state" width="badge" decoration-success="sat_state == 'valid'"
+                           decoration-danger="sat_state == 'cancelled'" decoration-warning="sat_state == 'not_found'"
+                           decoration-info="sat_state == 'none'" decoration-bf="sat_state == 'undefined'"/>
+                </list>
+            </field>
+        </record>
+
+        <record id="account_cfdi_view_form" model="ir.ui.view">
+            <field name="name">account_cfdi_view_form</field>
+            <field name="model">account.cfdi</field>
+            <field name="arch" type="xml">
+                <form string="Complementos CFDI">
+                    <header>
+                        <button name="action_done" default_focus="1" string="Procesar" icon="fa-gear" type="object"
+                                class="btn btn-primary" invisible="cfdi_type not in ('SI','I','SE') or (cfdi_type in ('SI','I','SE') and state != 'draft')"
+                        />
+                        <button name="action_unlink_move" default_focus="1" string="Desvincular" type="object"
+                                class="btn btn-secondary" invisible="cfdi_type not in ('SI','I','SE') or (cfdi_type in ('SI','I','SE') and state != 'done')"
+                        />
+                        <field name="state" widget="statusbar"/>
+                    </header>
+                    <sheet>
+                        <div>
+                            <h1>
+                                <field name="name" readonly="1"/>
+                            </h1>
+                        </div>
+                        <group col="3">
+                            <group string="Complemento">
+                                <field name="uuid" readonly="1"/>
+                                <field name="cfdi_type" readonly="1"/>
+                                <field name="attachment_id" readonly="1"/>
+                                <field name="pdf_id" readonly="1"/>
+                                <field name="date" readonly="1"/>
+                                <field name="emitter_id" options="{'no_create': True, 'no_create_edit':True}"
+                                       readonly="1"/>
+                                <field name="receiver_id" options="{'no_create': True, 'no_create_edit':True}"
+                                       readonly="1"/>
+                                <field name="version" readonly="1"/>
+                                <field name="serie" readonly="1"/>
+                                <field name="folio" readonly="1"/>
+                            </group>
+                            <group string="Detalle">
+                                <field name="payment_method" readonly="1"/>
+                                <field name="certificate_number" readonly="1"/>
+                                <field name="payment_condition" readonly="1"/>
+                                <field name="subtotal" readonly="1"/>
+                                <field name="tax_total"/>                                
+                                <field name="total" readonly="1"/>  
+                                <field name="currency" readonly="1"/>
+                                <field name="cfdi_type" readonly="1"/>
+                                <field name="payment_type" readonly="1"/>
+                                <field name="location" readonly="1"/>
+                                <field name="move_id" readonly="1"/>
+                                <field name="company_id" readonly="1"/>
+                            </group>
+                            <group string="Contable">
+                                <field name="journal_id" options="{'no_create':True}"
+                                       domain="[('type','in',('purchase','sale'))]"/>
+                                <field name="payable_account_id" options="{'no_create':True}"/>
+                                <field name="account_id" options="{'no_create':True}"/>
+                                <field name="account_analytic_account_id" invisible="1"/>
+                                <field string="Distribución analítica" name="analytic_distribution"
+                                       widget="analytic_distribution"
+                                       groups="analytic.group_analytic_accounting"
+                                       optional="show"
+                                       options="{'product_field': 'product_tmpl_id', 'account_field': 'account_id', 'force_applicability': 'optional'}"
+                                />
+                                <field name="fiscal_position_id" options="{'no_create':True}"/>
+                                <field name="tax_iva_id" options="{'no_create':True}"/>
+                                <field name="tax_isr_id" options="{'no_create':True}"/>
+                            </group>
+                        </group>
+                        <notebook>
+                            <page string="Conceptos/Impuestos">
+                                <group string="Conceptos">
+                                    <field name="concept_ids" colspan="2" nolabel="1">
+                                        <list string="Conceptos" create="false" delete="false" editable="top">
+                                            <field name="description"/>
+                                            <field name="no_identification" optional="hide"/>
+                                            <field name="product_tmpl_id" string="Producto"
+                                                   options="{'no_create':True}"/>
+                                            <field name="account_id" options="{'no_create':True}"/>
+                                            <field name="product_code"/>
+                                            <field invisible="1" name="account_analytic_account_id"/>
+                                            <field string="Distribución analítica" name="analytic_distribution"
+                                                   widget="analytic_distribution"
+                                                   groups="analytic.group_analytic_accounting"
+                                                   optional="show"
+                                                   options="{'product_field': 'product_tmpl_id', 'account_field': 'account_id', 'force_applicability': 'optional'}"
+                                            />
+                                            <field name="quantity" sum="quantity"/>
+                                            <field name="uom_code"/>
+                                            <field name="unit_price" sum="unit_price"/>
+                                            <field name="discount" sum="Total"/>
+                                            <field name="amount" sum="amount"/>
+                                        </list>
+                                    </field>
+                                </group>
+                                <group string="Impuestos">
+                                    <field name="tax_ids" colspan="2" nolabel="1">
+                                        <list string="Impuestos" editable="bottom" create="0" delete="0">
+                                            <field name="base" readonly="1"/>
+                                            <field name="code" readonly="1"/>
+                                            <field name="factor_type" readonly="1"/>
+                                            <field name="rate" readonly="1"/>
+                                            <field name="amount" sum="Total" readonly="1"/>
+                                            <field name="tax_id" options="{'no_create':True}"/>
+                                        </list>
+                                    </field>
+                                </group>
+                            </page>
+                            <page string="Addenda" name="addenda_page" invisible="1">
+                                <group name="addenda_group">
+                                    <field name="delivery_number"/>
+                                    <field name="invoice_qty"/>
+                                </group>
+                            </page>
+                        </notebook>
+                    </sheet>
+                    <chatter/>
+                </form>
+            </field>
+        </record>
+
+        <record id="account_cfdi_view_search" model="ir.ui.view">
+            <field name="name">account_cfdi_view_search</field>
+            <field name="model">account.cfdi</field>
+            <field name="arch" type="xml">
+                <search>
+                    <field name="company_id" groups="base.group_multi_company" optional="show"/>
+                    <field name="uuid"/>
+                    <field name="serie"/>
+                    <field name="folio"/>
+                    <field name="cfdi_type"/>
+                    <field name="date"/>
+                    <field name="emitter_id"/>
+                    <field name="receiver_id"/>
+                    <field name="move_id"/>
+                    <field name="journal_id"/>
+                    <filter name="facturas_clientes" string="Facturas de clientes"
+                            domain="[('cfdi_type','=','I')]"/>
+                    <filter name="facturas_proveedor" string="Facturas de proveedor"
+                            domain="[('cfdi_type','=','SI')]"/>
+                    <filter name="notas_credito_clientes" string="Notas de crédito de clientes"
+                            domain="[('cfdi_type','=','E')]"/>
+                    <filter name="notas_credito_proveedor" string="Notas de crédito de proveedor"
+                            domain="[('cfdi_type','=','SE')]"/>
+                    <filter name="rep_clientes" string="REP de clientes" domain="[('cfdi_type','=','P')]"/>
+                    <filter name="rep_proveedor" string="REP de proveedor" domain="[('cfdi_type','=','SP')]"/>
+                    <filter name="nomina_empleados" string="Nómina de empleados"
+                            domain="[('cfdi_type','=','N')]"/>
+                    <filter name="nomina_propia" string="Nómina propia" domain="[('cfdi_type','=','SN')]"/>
+                    <filter name="facturas_traslado_clientes" string="Facturas de traslado de clientes"
+                            domain="[('cfdi_type','=','T')]"/>
+                    <filter name="facturas_traslado_proveedor" string="Facturas de traslado de proveedor"
+                            domain="[('cfdi_type','=','ST')]"/>
+                    <separator orientation="vertical"/>
+                    <filter name="state_draft" string="Por procesar" domain="[('state','=','draft')]"/>
+                    <filter name="state_done" string="Procesados" domain="[('state','=','done')]"/>
+                    <filter name="state_cancel" string="Anulados" domain="[('state','=','cancel')]"/>
+                    <separator orientation="vertical"/>
+                    <group expand="0" string="Agrupar por">
+                        <filter name="groupby_company" string="Empresa" domain="" context="{'group_by':'company_id'}"/>
+                        <separator orientation="vertical"/>
+                        <filter name="groupby_tipo_de_comprobante" string="Tipo de comprobante"
+                                context="{'group_by':'cfdi_type'}"/>
+                        <separator orientation="vertical"/>
+                        <filter name="groupby_fecha" string="Fecha" domain="" context="{'group_by':'date'}"/>
+                        <separator orientation="vertical"/>
+                        <filter name="groupby_emisor" string="Emisor" domain=""
+                                context="{'group_by':'emitter_id'}"/>
+                        <filter name="groupby_receptor" string="Receptor" context="{'group_by':'receiver_id'}"/>
+                        <separator orientation="vertical"/>
+                        <filter name="groupby_version" string="Versión" domain="" context="{'group_by':'version'}"/>
+                        <filter name="groupby_metodo_pago" string="Método de pago"
+                                context="{'group_by':'payment_method'}"/>
+                        <filter name="groupby_moneda" string="Moneda" domain="" context="{'group_by':'currency'}"/>
+                        <separator orientation="vertical"/>
+                    </group>
+                </search>
+            </field>
+        </record>
+
+        <record id="account_cfdi_action" model="ir.actions.act_window">
+            <field name="name">Complementos CFDI</field>
+            <field name="type">ir.actions.act_window</field>
+            <field name="res_model">account.cfdi</field>
+            <field name="view_mode">list,form</field>
+        </record>
+
+        <menuitem id="account_cfdi_action_parent_menu" parent="accountant.menu_accounting" sequence="10"
+                  name="SAT"/>
+        <menuitem action="account_cfdi_action" id="menu_action_boveda_fiscal_cfdi"
+                  parent="custom_sat_connection.account_cfdi_action_parent_menu" sequence="100"
+                  name="Complementos CFDI"/>
+    </data>
+</odoo>

+ 61 - 0
custom_sat_connection/views/account_esignature_certificate.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<openerp>
+    <data>
+
+        <record id="sat_connection_account_esignature_certificate_tree" model="ir.ui.view">
+            <field name="name">sat_connection_account_esignature_certificate_tree</field>
+            <field name="model">account.esignature.certificate</field>
+            <field name="arch" type="xml">
+                <list string="Certificados">
+                    <field name="holder" string="Titular"/>
+                    <field name="holder_vat" string="RFC"/>
+                    <field name="serial_number" string="Número serial"/>
+                    <field name="date_start" string="Fecha de inicio"/>
+                    <field name="date_end" string="Fecha final"/>
+                </list>
+            </field>
+        </record>
+
+        <record id="sat_connection_account_esignature_certificate_form" model="ir.ui.view">
+            <field name="name">sat_connection_account_esignature_certificate_form</field>
+            <field name="model">account.esignature.certificate</field>
+            <field name="arch" type="xml">
+                <form string="Certificados">
+                    <sheet>
+                        <group>
+                            <field name="content" string="Certificado"/>
+                            <field name="key" string="Clave de certificado"/>
+                            <field name="password" password="True" string="Contraseña"/>
+                            <label for="date_start" string="Fecha de validación"/>
+                            <div>
+                                <field name="date_start" string="Fecha de inicio"/> -
+                                <field name="date_end" string="Fecha final"/>
+                            </div>
+                            <field name="serial_number" string="Número serial"/>
+                        </group>
+                    </sheet>
+                </form>
+            </field>
+        </record>
+
+        <record id="sat_connection_account_esignature_certificate_search" model="ir.ui.view">
+            <field name="name">sat_connection_account_esignature_certificate_search</field>
+            <field name="model">account.esignature.certificate</field>
+            <field name="arch" type="xml">
+                <search>
+                    <field name="holder" string="Titular"/>
+                    <field name="holder_vat" string="RFC"/>
+                </search>
+            </field>
+        </record>
+
+        <record id="sat_connection_account_esignature_certificate_action" model="ir.actions.act_window">
+            <field name="name">sat_connection_account_esignature_certificate_action</field>
+            <field name="res_model">account.esignature.certificate</field>
+            <field name="view_mode">tree,form</field>
+            <field name="help" type="html">
+                <p class="o_view_nocontent_smiling_face">Crear el primer certificado</p>
+            </field>
+        </record>
+    </data>
+</openerp>

+ 23 - 0
custom_sat_connection/views/account_journal.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="sat_connection_account_journal_form" model="ir.ui.view">
+            <field name="name">sat_connection_account_journal_form</field>
+            <field name="model">account.journal</field>
+            <field name="inherit_id" ref="account.view_account_journal_form"/>
+            <field name="arch" type="xml">
+
+                <xpath expr="//notebook/page[@name='advanced_settings']" position="after">
+                    <page name="SAT" string="SAT">
+                        <group>
+                            <field name="x_cfdi_type" string="Tipo de comprobante"/>
+                        </group>
+                    </page>
+                </xpath>
+
+            </field>
+        </record>
+
+    </data>
+</odoo>

+ 33 - 0
custom_sat_connection/views/account_move.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <record id="sat_connection_account_move_form" model="ir.ui.view">
+            <field name="name">sat_connection_account_move_form</field>
+            <field name="model">account.move</field>
+            <field name="inherit_id" ref="account.view_move_form"/>
+            <field name="arch" type="xml">
+
+                <xpath expr="//field[@name='ref']" position="after">
+                    <field name="cfdi_id" invisible="1"/>
+                    <field name="x_uuid" readonly="1"/>
+                </xpath>
+
+                <xpath expr="//notebook" position="inside">
+                    <page name="cfdi_data" string="CFDI información" invisible="1">
+                        <group>
+                            <group>
+                                <field name="x_delivery_number"/>
+                                <field name="x_invoice_qty"/>
+                            </group>
+                            <group>
+                            </group>
+                        </group>
+                    </page>
+                </xpath>
+
+            </field>
+        </record>
+
+
+    </data>
+</odoo>

+ 36 - 0
custom_sat_connection/views/res_config_settings.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <record id="sat_connection_res_config_settings_form" model="ir.ui.view">
+        <field name="name">sat_connection_res_config_settings_form</field>
+        <field name="model">res.config.settings</field>
+        <field name="inherit_id" ref="account.res_config_settings_view_form"/>
+        <field name="arch" type="xml">
+            <xpath expr="//block[@id='quick_edit_mode']" position="after">
+                <h2>Importación SAT</h2>
+                <div class="row mt16 o_settings_container" id="settings_sat_sync_configuration">
+                    <div class="col-12 col-lg-12 o_setting_box" title="Parametro para configurar el certificado de México.">
+                        <div class="o_setting_left_pane"/>
+                        <div class="o_setting_right_pane">
+                            <span class="o_form_label">Certificados MX</span>
+                            <div class="text-muted">
+                                Configuración de certificados fiscales.
+                            </div>
+                            <div class="content-group">
+                                <div class="row mt16">
+                                    <field name="x_esignature_ids">
+                                        <list>
+                                            <field name="date_start"/>
+                                            <field name="date_end"/>
+                                            <field name="holder"/>
+                                        </list>
+                                    </field>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </xpath>
+
+        </field>
+    </record>
+</odoo>

+ 3 - 0
custom_sat_connection/wizards/__init__.py

@@ -0,0 +1,3 @@
+from . import account_cfdi_sat
+from . import account_cfdi_zip
+from . import account_cfdi_xml

+ 10 - 0
custom_sat_connection/wizards/account_cfdi_link.py

@@ -0,0 +1,10 @@
+from odoo import api, fields, models
+
+class AccountCfdiLink(models.TransientModel):
+    _name = 'account.cfdi.link'
+    _description = 'Vinculación CFDI y asiento contable'
+    _rec_name = "cfdi_id"
+
+    cfdi_id = fields.Many2one(comodel_name="account.cfdi", string="CFDI")
+
+

+ 19 - 0
custom_sat_connection/wizards/account_cfdi_sat.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class ImportXML(models.TransientModel):
+    _name = 'account.cfdi.sat'
+    _description = 'Importación CFDI desde el SAT'
+
+    date_from = fields.Date(string='Desde', default=fields.Date.today())
+    date_to = fields.Date(string='Hasta', default=fields.Date.today())
+    type = fields.Selection(selection=[('0', 'Todo'), ('1', 'Emitidas'), ('2', 'Recibidas')], string='Tipo', default='0')
+    company_id = fields.Many2one(comodel_name='res.company', string='Empresa', default=lambda self: self.env.company, readonly=True)
+
+    def import_sat(self):
+        response = self.company_id.download_cfdi_invoices_sat(self.date_from, self.date_to, "supplier")
+        return response

+ 44 - 0
custom_sat_connection/wizards/account_cfdi_sat.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="account_cfdi_sat_view_form" model="ir.ui.view">
+            <field name="name">account_cfdi_sat_view_form</field>
+            <field name="model">account.cfdi.sat</field>
+            <field name="arch" type="xml">
+                <form string="Importación SAT">
+                    <sheet>
+                        <group>
+                            <group>
+                                <label for="date_from" string="Fechas"/>
+                                <div class="o_row">
+                                    <field name="date_from"/><span><![CDATA[&nbsp;]]>a<![CDATA[&nbsp;]]></span><field
+                                        name="date_to"/>
+                                </div>
+                            </group>
+                            <group>
+                                <field name="company_id"/>
+                            </group>
+                        </group>
+                    </sheet>
+                    <footer>
+                        <button name="import_sat" string="Importar" type="object" default_focus="1"
+                                class="btn btn-primary"/>
+                        <button string="Cancelar" class="btn btn-secondary" special="cancel"/>
+                    </footer>
+                </form>
+            </field>
+        </record>
+
+        <record id="account_cfdi_sat_action" model="ir.actions.act_window">
+            <field name="name">Importación SAT</field>
+            <field name="res_model">account.cfdi.sat</field>
+            <field name="type">ir.actions.act_window</field>
+            <field name="view_mode">form</field>
+            <field name="target">new</field>
+        </record>
+
+        <menuitem action="account_cfdi_sat_action" id="menu_import_sat" parent="custom_sat_connection.account_cfdi_action_parent_menu" sequence="30"/>
+
+    </data>
+</odoo>

+ 56 - 0
custom_sat_connection/wizards/account_cfdi_xml.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+import logging
+_logger = logging.getLogger(__name__)
+
+class AccountCfdiXml(models.TransientModel):
+    _name = 'account.cfdi.xml'
+    _description = 'Importación por XML'
+
+    company_id = fields.Many2one(comodel_name='res.company', string='Compañía', default=lambda self: self.env.company, readonly=True)
+    filedata_file = fields.Many2many(comodel_name='ir.attachment', string='Archivos XML')
+    filedata_name = fields.Char(string="Nombre de archivo")
+
+    def import_file(self):
+        if len(self.filedata_file) > 0:
+            attachment_list = []
+            for content in self.filedata_file:
+                try:
+                    attachment_data = {
+                        'name': content.name,
+                        'type': 'binary',
+                        'company_id': self.company_id.id,
+                        'datas': content.datas,
+                        'store_fname': content.name,
+                        'mimetype': 'application/xml'
+                    }
+                    data_uuid = {
+                        "xml": attachment_data,
+                    }
+                    attachment_list.append(data_uuid)
+                except Exception as e:
+                    _logger.info(e)
+            if attachment_list:
+                cfdi_ids = self.env['account.cfdi'].create_cfdis(attachment_list)
+                if cfdi_ids:
+                    return {
+                        "name": _("CFDIs importados"),
+                        "view_mode": "list,form",
+                        "res_model": "account.cfdi",
+                        "type": "ir.actions.act_window",
+                        "target": "current",
+                        "domain": [("id", "=", cfdi_ids.ids)]
+                    }
+                else:
+                    return {
+                        'type': 'ir.actions.client',
+                        'tag': 'display_notification',
+                        'params': {
+                            'title': _("No se cargaron nuevos CFDIs al sistema ya que estos ya existen o no pertenecen a la empresa, favor de validar."),
+                            'type': 'warning',
+                            'sticky': True,
+                        },
+                    }
+        else:
+            raise ValidationError(_('No ha subido ningún archivo XML'))

+ 30 - 0
custom_sat_connection/wizards/account_cfdi_xml.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+    <record id="sat_connection_account_cfdi_xml_form" model="ir.ui.view">
+        <field name="name">sat_connection_account_cfdi_xml_form</field>
+        <field name="model">account.cfdi.xml</field>
+        <field name="arch" type="xml">
+            <form class="form-xml">
+                <sheet>
+                    <field name="filedata_file"  widget="many2many_binary" required="1" placeholder="Seleccione los xml a subir..."/>
+                    <field name="filedata_name" invisible="1"/>
+                </sheet>
+                <footer>
+                    <button name="import_file" string="Cargar" type="object" class="btn-primary"/>
+                    <button string="Cerrar" class="btn-secondary" special="cancel"/>
+                </footer>
+            </form>
+        </field>
+    </record>
+
+    <record id="sat_connection_account_cfdi_xml_action" model="ir.actions.act_window">
+        <field name="name">Importación por XML</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name='res_model'>account.cfdi.xml</field>
+        <field name="view_mode">form</field>
+        <field name="context">{'l10n_mx_edi_invoice_type': 'in'}</field>
+        <field name="target">new</field>
+    </record>
+
+    <menuitem action="sat_connection_account_cfdi_xml_action" id="sat_connection_account_cfdi_xml_action_menu" name="Importación por XML" parent="custom_sat_connection.account_cfdi_action_parent_menu" sequence="50"/>
+</odoo>

+ 76 - 0
custom_sat_connection/wizards/account_cfdi_zip.py

@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+from odoo.exceptions import RedirectWarning, ValidationError
+from zipfile import ZipFile
+
+import base64
+import tempfile
+import os
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountCfdiZip(models.TransientModel):
+    _name = 'account.cfdi.zip'
+    _description = 'Importación con archivo ZIP'
+
+    file = fields.Binary(string='Archivo', required=True)
+    file_name = fields.Char(string='Nombre del archivo', required=True)
+    company_id = fields.Many2one(comodel_name='res.company', string='Empresa', default=lambda self: self.env.company, readonly=True)
+    result = fields.Char(string='Resultado')
+    state = fields.Selection(selection=[('draft', 'Seleccionar'), ('done', 'Importado'), ], string='Estado', default='draft')
+
+    def import_zip(self):
+        count_xml = 0
+        if self.file:
+            zip_file_id = self.env['ir.attachment'].create({
+                'name': self.file_name,
+                'type': 'binary',
+                'company_id': self.company_id.id,
+                'datas': self.file,
+                'store_fname': self.file_name,
+                'mimetype': 'application/zip'
+            })
+            fd, path = tempfile.mkstemp()
+            with os.fdopen(fd, 'wb') as tmp:
+                tmp.write(base64.b64decode(zip_file_id.datas))
+
+            try:
+                with ZipFile(path, 'r') as zip:
+                    attachment_list = []
+                    for filename in zip.namelist():
+                        with zip.open(filename) as file:
+                            xml_content = file.read()
+                            attachment_data = {
+                                'name': filename,
+                                'type': 'binary',
+                                'company_id': self.company_id.id,
+                                'datas': base64.b64encode(xml_content),
+                                'store_fname': filename,
+                                'mimetype': 'application/xml'
+                            }
+                            data_uuid = {
+                                "xml": attachment_data,
+                            }
+                            attachment_list.append(data_uuid)
+                    if attachment_list:
+                        cfdi_ids = self.env['account.cfdi'].create_cfdis(attachment_list)
+                        count_xml = len(cfdi_ids)
+            except Exception as e:
+                raise ValidationError(e)
+
+        self.write({
+            'result': "Archivos XML procesados correctamente: " + str(count_xml),
+            'state': 'done',
+        })
+
+        return {
+            'type': 'ir.actions.act_window',
+            'res_model': 'account.cfdi.zip',
+            'view_mode': 'form',
+            'view_type': 'form',
+            'res_id': self.id,
+            'views': [(False, 'form')],
+            'target': 'new',
+        }

+ 38 - 0
custom_sat_connection/wizards/account_cfdi_zip.xml

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <record id="sat_connection_account_cfdi_zip_form" model="ir.ui.view">
+        <field name="name">sat_connection_account_cfdi_zip_form</field>
+        <field name="model">account.cfdi.zip</field>
+        <field name="arch" type="xml">
+            <form string="Importar archivo ZIP">
+                <sheet>
+                    <field name="state" invisible="1"/>
+                    <group name="datos" string="Subir archivo" >
+                        <field name="file" filename="file_name"/>
+                        <field name="file_name" invisible="1"/>
+                        <field name="company_id"/>
+                    </group>
+                    <group>
+                        <separator string="Importación finalizada" colspan="4"/>
+                    </group>
+                    <field name="result" readonly="1" nolabel="1"/>
+                </sheet>
+                <footer>
+                    <button name="import_zip" string="Importar" type="object" default_focus="1" class="oe_highlight"/>
+                    <button string="Cerrar" class="oe_link" special="cancel"/>
+                </footer>
+            </form>
+        </field>
+    </record>
+
+    <record id="sat_connection_account_cfdi_zip_action" model="ir.actions.act_window">
+        <field name="name">Importación ZIP</field>
+        <field name="res_model">account.cfdi.zip</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="view_mode">form</field>
+        <field name="target">new</field>
+    </record>
+
+    <menuitem action="sat_connection_account_cfdi_zip_action" id="menu_import_zip" parent="custom_sat_connection.account_cfdi_action_parent_menu" sequence="40"/>
+
+</odoo>

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+plotly
+httpx==0.13.3
+xmltodict==0.13.0