feat(pos): add CFDI 4.0 XML builder — Ingreso, Egreso, Pago with lxml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 04:13:08 +00:00
parent 62ea08de9f
commit 4e6bac8661

View File

@@ -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')