diff --git a/pos/services/cfdi_builder.py b/pos/services/cfdi_builder.py index 491413a..c2bcf4c 100644 --- a/pos/services/cfdi_builder.py +++ b/pos/services/cfdi_builder.py @@ -1,21 +1,451 @@ # /home/Autopartes/pos/services/cfdi_builder.py -"""CFDI 4.0 XML builder — stub for future implementation (Plan 4, Task 2). +"""CFDI 4.0 XML builder using lxml. -Full implementation will build CFDI 4.0 compliant XML for ingreso, egreso, -and pago voucher types using lxml. +Builds unsigned CFDI XML documents for: +- Ingreso (sale invoice) +- Egreso (credit note / refund) +- Pago (payment complement for credit sales) + +The unsigned XML is sent to Horux360 for CSD signing and PAC timbrado. +All XML follows the SAT CFDI 4.0 schema: + http://www.sat.gob.mx/cfd/4 + +CFDI 4.0 mandatory fields handled: +- Version="4.0" +- Exportacion="01" (no aplica) +- ObjetoImp="02" (si, objeto de impuesto) on each Concepto +- InformacionGlobal for publico general (RFC: XAXX010101000) +- Emisor: Rfc, Nombre, RegimenFiscal +- Receptor: Rfc, Nombre, RegimenFiscalReceptor, UsoCFDI, DomicilioFiscalReceptor """ +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from lxml import etree -def build_ingreso_xml(sale, tenant_config, customer): - """Build unsigned CFDI 4.0 XML for an ingreso (income) voucher.""" - raise NotImplementedError("cfdi_builder.build_ingreso_xml not yet implemented (Plan 4, Task 2)") +# SAT XML namespaces +CFDI_NS = 'http://www.sat.gob.mx/cfd/4' +PAGO_NS = 'http://www.sat.gob.mx/Pagos20' +XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance' + +CFDI_SCHEMA_LOCATION = ( + 'http://www.sat.gob.mx/cfd/4 ' + 'http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd' +) +PAGO_SCHEMA_LOCATION = ( + 'http://www.sat.gob.mx/Pagos20 ' + 'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos20.xsd' +) + +# RFC for publico en general +RFC_PUBLICO_GENERAL = 'XAXX010101000' +# RFC for foreign customers +RFC_EXTRANJERO = 'XEXX010101000' + + +def _to_dec(val): + if val is None: + return Decimal('0') + return Decimal(str(val)) + + +TWO = Decimal('0.01') +SIX = Decimal('0.000001') + + +def _format_amount(val): + """Format a Decimal to 2 decimal places as string.""" + return str(_to_dec(val).quantize(TWO, ROUND_HALF_UP)) + + +def _format_rate(val): + """Format a tax rate to 6 decimal places as string.""" + return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP)) + + +def _make_element(parent, tag, attribs=None, ns=CFDI_NS): + """Create a subelement with the given namespace.""" + elem = etree.SubElement(parent, f'{{{ns}}}{tag}') + if attribs: + for k, v in attribs.items(): + if v is not None: + elem.set(k, str(v)) + return elem + + +def build_ingreso_xml(sale, tenant_config, customer=None): + """Build CFDI 4.0 XML for a sale (Comprobante tipo Ingreso). + + Args: + sale: dict with keys: + id, subtotal, discount_total, tax_total, total, created_at, + metodo_pago_sat ('PUE'|'PPD'), forma_pago_sat ('01'|'03'|'04'|'99'), + items: [{part_number, name, quantity, unit_price, discount_amount, + tax_rate, tax_amount, subtotal, + clave_prod_serv, clave_unidad}] + tenant_config: dict with keys: + rfc, razon_social, regimen_fiscal, cp (codigo postal), + serie (optional), nombre_comercial (optional) + customer: dict or None with keys: + rfc, razon_social, regimen_fiscal, uso_cfdi, cp + If None, generates factura a publico general. + + Returns: + str: XML string (unsigned, ready for Horux) + """ + nsmap = { + 'cfdi': CFDI_NS, + 'xsi': XSI_NS, + } + + # Root: Comprobante + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION) + root.set('Version', '4.0') + root.set('Serie', tenant_config.get('serie', 'A')) + root.set('Folio', str(sale['id'])) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('FormaPago', sale.get('forma_pago_sat', '01')) + root.set('SubTotal', _format_amount(sale['subtotal'])) + + discount_total = _to_dec(sale.get('discount_total', 0)) + if discount_total > 0: + root.set('Descuento', _format_amount(discount_total)) + + root.set('Moneda', 'MXN') + root.set('Total', _format_amount(sale['total'])) + root.set('TipoDeComprobante', 'I') # Ingreso + root.set('Exportacion', '01') # No aplica + root.set('MetodoPago', sale.get('metodo_pago_sat', 'PUE')) + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # InformacionGlobal (required for publico general) + is_publico_general = (customer is None or + customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL)) + + if is_publico_general: + info_global = _make_element(root, 'InformacionGlobal') + info_global.set('Periodicidad', '01') # Diario + now = datetime.now() + info_global.set('Meses', f'{now.month:02d}') + info_global.set('Anio', str(now.year)) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + receptor = _make_element(root, 'Receptor') + if is_publico_general: + receptor.set('Rfc', RFC_PUBLICO_GENERAL) + receptor.set('Nombre', 'PUBLICO EN GENERAL') + receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', '616') # Sin obligaciones fiscales + receptor.set('UsoCFDI', 'S01') # Sin efectos fiscales + else: + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03')) + + # Conceptos + conceptos = _make_element(root, 'Conceptos') + + for item in sale.get('items', []): + qty = int(item.get('quantity', 1)) + unit_price = _to_dec(item.get('unit_price', 0)) + discount_amount = _to_dec(item.get('discount_amount', 0)) + tax_rate = _to_dec(item.get('tax_rate', '0.16')) + tax_amount = _to_dec(item.get('tax_amount', 0)) + + # Importe = qty * unit_price (before discount) + importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP) + # Base for tax = importe - discount + base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) + + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800')) # Default: autopartes + concepto.set('NoIdentificacion', item.get('part_number', '')) + concepto.set('Cantidad', str(qty)) + concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87')) # H87 = Pieza + concepto.set('Unidad', 'PZA') + concepto.set('Descripcion', item.get('name', 'Autoparte')) + concepto.set('ValorUnitario', _format_amount(unit_price)) + concepto.set('Importe', _format_amount(importe)) + concepto.set('ObjetoImp', '02') # Si objeto de impuesto + + if discount_amount > 0: + concepto.set('Descuento', _format_amount(discount_amount)) + + # Impuestos del concepto + impuestos_concepto = _make_element(concepto, 'Impuestos') + traslados_concepto = _make_element(impuestos_concepto, 'Traslados') + traslado = _make_element(traslados_concepto, 'Traslado') + traslado.set('Base', _format_amount(base)) + traslado.set('Impuesto', '002') # IVA + traslado.set('TipoFactor', 'Tasa') + traslado.set('TasaOCuota', _format_rate(tax_rate)) + traslado.set('Importe', _format_amount(tax_amount)) + + # Impuestos totales + impuestos = _make_element(root, 'Impuestos') + impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total'])) + traslados = _make_element(impuestos, 'Traslados') + traslado_total = _make_element(traslados, 'Traslado') + traslado_total.set('Base', _format_amount(sale['subtotal'])) + traslado_total.set('Impuesto', '002') + traslado_total.set('TipoFactor', 'Tasa') + traslado_total.set('TasaOCuota', '0.160000') + traslado_total.set('Importe', _format_amount(sale['tax_total'])) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8') def build_egreso_xml(sale, tenant_config, customer, original_uuid): - """Build unsigned CFDI 4.0 XML for an egreso (credit note) voucher.""" - raise NotImplementedError("cfdi_builder.build_egreso_xml not yet implemented (Plan 4, Task 2)") + """Build CFDI 4.0 XML for a credit note (Comprobante tipo Egreso). + + Used for cancellations or returns that require a nota de credito. + + Args: + sale: same as build_ingreso_xml + tenant_config: same as build_ingreso_xml + customer: same as build_ingreso_xml + original_uuid: str UUID of the original CFDI being credited + + Returns: + str: XML string + """ + nsmap = { + 'cfdi': CFDI_NS, + 'xsi': XSI_NS, + } + + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION) + root.set('Version', '4.0') + root.set('Serie', 'NC') + root.set('Folio', str(sale['id'])) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('FormaPago', sale.get('forma_pago_sat', '01')) + root.set('SubTotal', _format_amount(sale['subtotal'])) + + discount_total = _to_dec(sale.get('discount_total', 0)) + if discount_total > 0: + root.set('Descuento', _format_amount(discount_total)) + + root.set('Moneda', 'MXN') + root.set('Total', _format_amount(sale['total'])) + root.set('TipoDeComprobante', 'E') # Egreso + root.set('Exportacion', '01') + root.set('MetodoPago', 'PUE') + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # CfdiRelacionados - references the original CFDI + cfdi_relacionados = _make_element(root, 'CfdiRelacionados') + cfdi_relacionados.set('TipoRelacion', '01') # Nota de credito + cfdi_relacionado = _make_element(cfdi_relacionados, 'CfdiRelacionado') + cfdi_relacionado.set('UUID', original_uuid) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + is_publico_general = (customer is None or + customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL)) + receptor = _make_element(root, 'Receptor') + if is_publico_general: + receptor.set('Rfc', RFC_PUBLICO_GENERAL) + receptor.set('Nombre', 'PUBLICO EN GENERAL') + receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', '616') + receptor.set('UsoCFDI', 'S01') + else: + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03')) + + # Conceptos (same as ingreso but for the credited amounts) + conceptos = _make_element(root, 'Conceptos') + + for item in sale.get('items', []): + qty = int(item.get('quantity', 1)) + unit_price = _to_dec(item.get('unit_price', 0)) + discount_amount = _to_dec(item.get('discount_amount', 0)) + tax_rate = _to_dec(item.get('tax_rate', '0.16')) + tax_amount = _to_dec(item.get('tax_amount', 0)) + importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP) + base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) + + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800')) + concepto.set('NoIdentificacion', item.get('part_number', '')) + concepto.set('Cantidad', str(qty)) + concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87')) + concepto.set('Unidad', 'PZA') + concepto.set('Descripcion', item.get('name', 'Autoparte')) + concepto.set('ValorUnitario', _format_amount(unit_price)) + concepto.set('Importe', _format_amount(importe)) + concepto.set('ObjetoImp', '02') + + if discount_amount > 0: + concepto.set('Descuento', _format_amount(discount_amount)) + + impuestos_concepto = _make_element(concepto, 'Impuestos') + traslados_concepto = _make_element(impuestos_concepto, 'Traslados') + traslado = _make_element(traslados_concepto, 'Traslado') + traslado.set('Base', _format_amount(base)) + traslado.set('Impuesto', '002') + traslado.set('TipoFactor', 'Tasa') + traslado.set('TasaOCuota', _format_rate(tax_rate)) + traslado.set('Importe', _format_amount(tax_amount)) + + # Impuestos totales + impuestos = _make_element(root, 'Impuestos') + impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total'])) + traslados = _make_element(impuestos, 'Traslados') + traslado_total = _make_element(traslados, 'Traslado') + traslado_total.set('Base', _format_amount(sale['subtotal'])) + traslado_total.set('Impuesto', '002') + traslado_total.set('TipoFactor', 'Tasa') + traslado_total.set('TasaOCuota', '0.160000') + traslado_total.set('Importe', _format_amount(sale['tax_total'])) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8') -def build_pago_xml(sale, tenant_config, customer, payments): - """Build unsigned CFDI 4.0 XML for a pago (payment) complement.""" - raise NotImplementedError("cfdi_builder.build_pago_xml not yet implemented (Plan 4, Task 2)") +def build_pago_xml(payment, tenant_config, customer, original_uuid): + """Build CFDI 4.0 XML with Complemento de Pago 2.0. + + Used for credit sale payments (MetodoPago PPD). When a customer + pays an outstanding credit sale, this generates the payment complement. + + Args: + payment: dict with keys: + id, amount, payment_method ('efectivo'|'transferencia'|'tarjeta'), + date (ISO string), reference (optional) + tenant_config: same as build_ingreso_xml + customer: dict with RFC data + original_uuid: str UUID of the original CFDI (Ingreso with PPD) + + Returns: + str: XML string + """ + nsmap = { + 'cfdi': CFDI_NS, + 'pago20': PAGO_NS, + 'xsi': XSI_NS, + } + + forma_pago_map = { + 'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99' + } + + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', + f'{CFDI_SCHEMA_LOCATION} {PAGO_SCHEMA_LOCATION}') + root.set('Version', '4.0') + root.set('Serie', 'P') + root.set('Folio', str(payment.get('id', ''))) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('SubTotal', '0') + root.set('Moneda', 'XXX') # Required for Pago type + root.set('Total', '0') + root.set('TipoDeComprobante', 'P') # Pago + root.set('Exportacion', '01') + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + receptor = _make_element(root, 'Receptor') + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', 'CP01') # Pagos + + # Conceptos (mandatory placeholder for Pago type) + conceptos = _make_element(root, 'Conceptos') + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', '84111506') # Servicios de facturacion + concepto.set('Cantidad', '1') + concepto.set('ClaveUnidad', 'ACT') # Actividad + concepto.set('Descripcion', 'Pago') + concepto.set('ValorUnitario', '0') + concepto.set('Importe', '0') + concepto.set('ObjetoImp', '01') # No objeto de impuesto + + # Complemento de Pago 2.0 + complemento = _make_element(root, 'Complemento') + pagos_elem = etree.SubElement(complemento, f'{{{PAGO_NS}}}Pagos') + pagos_elem.set('Version', '2.0') + + # Totales + totales = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Totales') + amount = _to_dec(payment['amount']) + # Calculate base and IVA from total (total includes 16% IVA) + base_pago = (amount / Decimal('1.16')).quantize(TWO, ROUND_HALF_UP) + iva_pago = (amount - base_pago).quantize(TWO, ROUND_HALF_UP) + totales.set('TotalTrasladosBaseIVA16', _format_amount(base_pago)) + totales.set('TotalTrasladosImpuestoIVA16', _format_amount(iva_pago)) + totales.set('MontoTotalPagos', _format_amount(amount)) + + # Pago + pago = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Pago') + payment_date = payment.get('date', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + pago.set('FechaPago', payment_date if 'T' in str(payment_date) + else f'{payment_date}T12:00:00') + pago.set('FormaDePagoP', forma_pago_map.get(payment.get('payment_method', 'efectivo'), '01')) + pago.set('MonedaP', 'MXN') + pago.set('Monto', _format_amount(amount)) + + # DoctoRelacionado (the original invoice being paid) + docto = etree.SubElement(pago, f'{{{PAGO_NS}}}DoctoRelacionado') + docto.set('IdDocumento', original_uuid) + docto.set('Serie', 'A') + docto.set('Folio', str(payment.get('sale_id', ''))) + docto.set('MonedaDR', 'MXN') + docto.set('NumParcialidad', str(payment.get('num_parcialidad', 1))) + docto.set('ImpSaldoAnt', _format_amount(payment.get('saldo_anterior', amount))) + docto.set('ImpPagado', _format_amount(amount)) + saldo_insoluto = _to_dec(payment.get('saldo_anterior', amount)) - amount + docto.set('ImpSaldoInsoluto', _format_amount(max(saldo_insoluto, Decimal('0')))) + docto.set('ObjetoImpDR', '02') + docto.set('EquivalenciaDR', '1') + + # ImpuestosDR + impuestos_dr = etree.SubElement(docto, f'{{{PAGO_NS}}}ImpuestosDR') + traslados_dr = etree.SubElement(impuestos_dr, f'{{{PAGO_NS}}}TrasladosDR') + traslado_dr = etree.SubElement(traslados_dr, f'{{{PAGO_NS}}}TrasladoDR') + traslado_dr.set('BaseDR', _format_amount(base_pago)) + traslado_dr.set('ImpuestoDR', '002') + traslado_dr.set('TipoFactorDR', 'Tasa') + traslado_dr.set('TasaOCuotaDR', '0.160000') + traslado_dr.set('ImporteDR', _format_amount(iva_pago)) + + # ImpuestosP (pago-level taxes) + impuestos_p = etree.SubElement(pago, f'{{{PAGO_NS}}}ImpuestosP') + traslados_p = etree.SubElement(impuestos_p, f'{{{PAGO_NS}}}TrasladosP') + traslado_p = etree.SubElement(traslados_p, f'{{{PAGO_NS}}}TrasladoP') + traslado_p.set('BaseP', _format_amount(base_pago)) + traslado_p.set('ImpuestoP', '002') + traslado_p.set('TipoFactorP', 'Tasa') + traslado_p.set('TasaOCuotaP', '0.160000') + traslado_p.set('ImporteP', _format_amount(iva_pago)) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8')