Skip to main content

cfdi_cli/
parser.rs

1use std::fs;
2use std::path::Path;
3
4use quick_xml::events::Event;
5use quick_xml::Reader;
6
7use crate::models::*;
8
9#[derive(Debug)]
10pub enum ParseError {
11    Io(std::io::Error),
12    Xml(quick_xml::Error),
13    NotCfdi(String),
14    MissingField(String),
15}
16
17impl std::fmt::Display for ParseError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            ParseError::Io(e) => write!(f, "IO error: {e}"),
21            ParseError::Xml(e) => write!(f, "XML error: {e}"),
22            ParseError::NotCfdi(msg) => write!(f, "Not a valid CFDI: {msg}"),
23            ParseError::MissingField(field) => write!(f, "Missing required field: {field}"),
24        }
25    }
26}
27
28impl std::error::Error for ParseError {}
29
30impl From<std::io::Error> for ParseError {
31    fn from(e: std::io::Error) -> Self {
32        ParseError::Io(e)
33    }
34}
35
36impl From<quick_xml::Error> for ParseError {
37    fn from(e: quick_xml::Error) -> Self {
38        ParseError::Xml(e)
39    }
40}
41
42/// Extract attributes as Vec<(String, String)> from a BytesStart event.
43fn extract_attrs(e: &quick_xml::events::BytesStart<'_>) -> Vec<(String, String)> {
44    e.attributes()
45        .flatten()
46        .map(|attr| {
47            let local = attr.key.local_name();
48            let key = std::str::from_utf8(local.as_ref())
49                .unwrap_or("")
50                .to_string();
51            let val = attr.unescape_value().unwrap_or_default().to_string();
52            (key, val)
53        })
54        .collect()
55}
56
57/// Get local element name from a BytesStart.
58fn local_name(e: &quick_xml::events::BytesStart<'_>) -> String {
59    let ln = e.local_name();
60    std::str::from_utf8(ln.as_ref()).unwrap_or("").to_string()
61}
62
63/// Get local element name from a BytesEnd.
64fn local_name_end(e: &quick_xml::events::BytesEnd<'_>) -> String {
65    let ln = e.local_name();
66    std::str::from_utf8(ln.as_ref()).unwrap_or("").to_string()
67}
68
69/// Find attribute value by key.
70fn attr_val(attrs: &[(String, String)], key: &str) -> Option<String> {
71    attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
72}
73
74fn attr_val_or(attrs: &[(String, String)], key: &str, default: &str) -> String {
75    attr_val(attrs, key).unwrap_or_else(|| default.to_string())
76}
77
78pub fn parse_file(path: &Path) -> Result<Comprobante, ParseError> {
79    let xml = fs::read_to_string(path)?;
80    parse_str(&xml)
81}
82
83pub fn parse_str(xml: &str) -> Result<Comprobante, ParseError> {
84    let mut reader = Reader::from_str(xml);
85
86    let mut comprobante_attrs: Option<Vec<(String, String)>> = None;
87    let mut emisor: Option<Emisor> = None;
88    let mut receptor: Option<Receptor> = None;
89    let mut conceptos: Vec<Concepto> = Vec::new();
90    let mut current_concepto_attrs: Option<Vec<(String, String)>> = None;
91    let mut concepto_traslados: Vec<Traslado> = Vec::new();
92    let mut concepto_retenciones: Vec<Retencion> = Vec::new();
93    let mut impuestos_attrs: Option<Vec<(String, String)>> = None;
94    let mut global_traslados: Vec<Traslado> = Vec::new();
95    let mut global_retenciones: Vec<Retencion> = Vec::new();
96    let mut timbre: Option<TimbreFiscalDigital> = None;
97
98    let mut in_conceptos = false;
99    let mut in_concepto_impuestos = false;
100    let mut in_impuestos_global = false;
101    let mut in_complemento = false;
102
103    loop {
104        let event = reader.read_event();
105        let (is_empty, event_ref) = match &event {
106            Ok(Event::Empty(_)) => (true, &event),
107            _ => (false, &event),
108        };
109        let _ = event_ref;
110
111        match event {
112            Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
113                let name = local_name(e);
114                let attrs = extract_attrs(e);
115
116                match name.as_str() {
117                    "Comprobante" => {
118                        if let Some(v) = attr_val(&attrs, "Version") {
119                            if v != "4.0" && v != "3.3" {
120                                return Err(ParseError::NotCfdi(format!(
121                                    "Unsupported CFDI version: {v}"
122                                )));
123                            }
124                        }
125                        comprobante_attrs = Some(attrs);
126                    }
127                    "Emisor" => {
128                        emisor = Some(Emisor {
129                            rfc: attr_val_or(&attrs, "Rfc", ""),
130                            nombre: attr_val(&attrs, "Nombre"),
131                            regimen_fiscal: attr_val_or(&attrs, "RegimenFiscal", ""),
132                        });
133                    }
134                    "Receptor" => {
135                        receptor = Some(Receptor {
136                            rfc: attr_val_or(&attrs, "Rfc", ""),
137                            nombre: attr_val(&attrs, "Nombre"),
138                            uso_cfdi: attr_val_or(&attrs, "UsoCFDI", ""),
139                            domicilio_fiscal_receptor: attr_val(&attrs, "DomicilioFiscalReceptor"),
140                            regimen_fiscal_receptor: attr_val(&attrs, "RegimenFiscalReceptor"),
141                        });
142                    }
143                    "Conceptos" => {
144                        in_conceptos = true;
145                    }
146                    "Concepto" if in_conceptos => {
147                        if is_empty {
148                            // Self-closing <Concepto .../> — push immediately
149                            conceptos.push(Concepto {
150                                clave_prod_serv: attr_val_or(&attrs, "ClaveProdServ", ""),
151                                cantidad: attr_val_or(&attrs, "Cantidad", ""),
152                                clave_unidad: attr_val_or(&attrs, "ClaveUnidad", ""),
153                                unidad: attr_val(&attrs, "Unidad"),
154                                descripcion: attr_val_or(&attrs, "Descripcion", ""),
155                                valor_unitario: attr_val_or(&attrs, "ValorUnitario", ""),
156                                importe: attr_val_or(&attrs, "Importe", ""),
157                                descuento: attr_val(&attrs, "Descuento"),
158                                objeto_imp: attr_val(&attrs, "ObjetoImp"),
159                                impuestos: None,
160                            });
161                        } else {
162                            current_concepto_attrs = Some(attrs);
163                            concepto_traslados.clear();
164                            concepto_retenciones.clear();
165                        }
166                    }
167                    "Impuestos" if current_concepto_attrs.is_some() && in_conceptos => {
168                        in_concepto_impuestos = true;
169                    }
170                    "Impuestos" if !in_conceptos => {
171                        impuestos_attrs = Some(attrs);
172                        in_impuestos_global = true;
173                        global_traslados.clear();
174                        global_retenciones.clear();
175                    }
176                    "Traslado" => {
177                        let t = Traslado {
178                            base: attr_val_or(&attrs, "Base", ""),
179                            impuesto: attr_val_or(&attrs, "Impuesto", ""),
180                            tipo_factor: attr_val_or(&attrs, "TipoFactor", ""),
181                            tasa_o_cuota: attr_val(&attrs, "TasaOCuota"),
182                            importe: attr_val(&attrs, "Importe"),
183                        };
184                        if in_concepto_impuestos {
185                            concepto_traslados.push(t);
186                        } else if in_impuestos_global {
187                            global_traslados.push(t);
188                        }
189                    }
190                    "Retencion" => {
191                        let r = Retencion {
192                            base: attr_val(&attrs, "Base"),
193                            impuesto: attr_val_or(&attrs, "Impuesto", ""),
194                            tipo_factor: attr_val(&attrs, "TipoFactor"),
195                            tasa_o_cuota: attr_val(&attrs, "TasaOCuota"),
196                            importe: attr_val_or(&attrs, "Importe", ""),
197                        };
198                        if in_concepto_impuestos {
199                            concepto_retenciones.push(r);
200                        } else if in_impuestos_global {
201                            global_retenciones.push(r);
202                        }
203                    }
204                    "Complemento" => {
205                        in_complemento = true;
206                    }
207                    "TimbreFiscalDigital" if in_complemento => {
208                        timbre = Some(TimbreFiscalDigital {
209                            uuid: attr_val_or(&attrs, "UUID", ""),
210                            fecha_timbrado: attr_val_or(&attrs, "FechaTimbrado", ""),
211                            rfc_prov_certif: attr_val(&attrs, "RfcProvCertif"),
212                            sello_cfd: attr_val_or(&attrs, "SelloCFD", ""),
213                            no_certificado_sat: attr_val_or(&attrs, "NoCertificadoSAT", ""),
214                            sello_sat: attr_val_or(&attrs, "SelloSAT", ""),
215                        });
216                    }
217                    _ => {}
218                }
219            }
220            Ok(Event::End(ref e)) => {
221                let name = local_name_end(e);
222                match name.as_str() {
223                    "Conceptos" => in_conceptos = false,
224                    "Concepto" if current_concepto_attrs.is_some() => {
225                        if let Some(ca) = current_concepto_attrs.take() {
226                            let imp = if concepto_traslados.is_empty()
227                                && concepto_retenciones.is_empty()
228                            {
229                                None
230                            } else {
231                                Some(ConceptoImpuestos {
232                                    traslados: std::mem::take(&mut concepto_traslados),
233                                    retenciones: std::mem::take(&mut concepto_retenciones),
234                                })
235                            };
236                            conceptos.push(Concepto {
237                                clave_prod_serv: attr_val_or(&ca, "ClaveProdServ", ""),
238                                cantidad: attr_val_or(&ca, "Cantidad", ""),
239                                clave_unidad: attr_val_or(&ca, "ClaveUnidad", ""),
240                                unidad: attr_val(&ca, "Unidad"),
241                                descripcion: attr_val_or(&ca, "Descripcion", ""),
242                                valor_unitario: attr_val_or(&ca, "ValorUnitario", ""),
243                                importe: attr_val_or(&ca, "Importe", ""),
244                                descuento: attr_val(&ca, "Descuento"),
245                                objeto_imp: attr_val(&ca, "ObjetoImp"),
246                                impuestos: imp,
247                            });
248                        }
249                        in_concepto_impuestos = false;
250                    }
251                    "Impuestos" if in_concepto_impuestos => in_concepto_impuestos = false,
252                    "Impuestos" if in_impuestos_global => in_impuestos_global = false,
253                    "Complemento" => in_complemento = false,
254                    _ => {}
255                }
256            }
257            Ok(Event::Eof) => break,
258            Err(e) => return Err(ParseError::Xml(e)),
259            _ => {}
260        }
261    }
262
263    let ca = comprobante_attrs
264        .ok_or_else(|| ParseError::NotCfdi("No Comprobante element found".to_string()))?;
265    let emisor = emisor.ok_or_else(|| ParseError::MissingField("Emisor".to_string()))?;
266    let receptor = receptor.ok_or_else(|| ParseError::MissingField("Receptor".to_string()))?;
267
268    let impuestos = impuestos_attrs.map(|ia| Impuestos {
269        total_impuestos_trasladados: attr_val(&ia, "TotalImpuestosTrasladados"),
270        total_impuestos_retenidos: attr_val(&ia, "TotalImpuestosRetenidos"),
271        traslados: global_traslados,
272        retenciones: global_retenciones,
273    });
274
275    let complemento = timbre.map(|t| Complemento {
276        timbre_fiscal: Some(t),
277    });
278
279    Ok(Comprobante {
280        version: attr_val_or(&ca, "Version", ""),
281        serie: attr_val(&ca, "Serie"),
282        folio: attr_val(&ca, "Folio"),
283        fecha: attr_val_or(&ca, "Fecha", ""),
284        sello: attr_val_or(&ca, "Sello", ""),
285        no_certificado: attr_val_or(&ca, "NoCertificado", ""),
286        certificado: attr_val_or(&ca, "Certificado", ""),
287        forma_pago: attr_val(&ca, "FormaPago"),
288        condiciones_de_pago: attr_val(&ca, "CondicionesDePago"),
289        sub_total: attr_val_or(&ca, "SubTotal", ""),
290        descuento: attr_val(&ca, "Descuento"),
291        moneda: attr_val_or(&ca, "Moneda", "MXN"),
292        tipo_cambio: attr_val(&ca, "TipoCambio"),
293        total: attr_val_or(&ca, "Total", ""),
294        tipo_de_comprobante: attr_val_or(&ca, "TipoDeComprobante", ""),
295        metodo_pago: attr_val(&ca, "MetodoPago"),
296        lugar_expedicion: attr_val_or(&ca, "LugarExpedicion", ""),
297        exportacion: attr_val(&ca, "Exportacion"),
298        emisor,
299        receptor,
300        conceptos,
301        impuestos,
302        complemento,
303    })
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn sample_cfdi() -> &'static str {
311        r#"<?xml version="1.0" encoding="utf-8"?>
312<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital" Version="4.0" Serie="A" Folio="123" Fecha="2024-01-15T10:30:00" Sello="abc123" NoCertificado="00001000000504465028" Certificado="CERT123" SubTotal="1000.00" Total="1160.00" TipoDeComprobante="I" MetodoPago="PUE" FormaPago="03" Moneda="MXN" LugarExpedicion="44100">
313  <cfdi:Emisor Rfc="AAA010101AAA" Nombre="Empresa Test SA de CV" RegimenFiscal="601"/>
314  <cfdi:Receptor Rfc="BBB020202BBB" Nombre="Cliente Test" UsoCFDI="G03" DomicilioFiscalReceptor="44100" RegimenFiscalReceptor="616"/>
315  <cfdi:Conceptos>
316    <cfdi:Concepto ClaveProdServ="84111506" Cantidad="1" ClaveUnidad="E48" Unidad="Servicio" Descripcion="Consultoria" ValorUnitario="1000.00" Importe="1000.00" ObjetoImp="02">
317      <cfdi:Impuestos>
318        <cfdi:Traslados>
319          <cfdi:Traslado Base="1000.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="160.00"/>
320        </cfdi:Traslados>
321      </cfdi:Impuestos>
322    </cfdi:Concepto>
323  </cfdi:Conceptos>
324  <cfdi:Impuestos TotalImpuestosTrasladados="160.00">
325    <cfdi:Traslados>
326      <cfdi:Traslado Base="1000.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="160.00"/>
327    </cfdi:Traslados>
328  </cfdi:Impuestos>
329  <cfdi:Complemento>
330    <tfd:TimbreFiscalDigital UUID="6128396F-0000-1111-2222-333344445555" FechaTimbrado="2024-01-15T10:31:00" RfcProvCertif="SAT970701NN3" SelloCFD="abc123" NoCertificadoSAT="00001000000504465028" SelloSAT="xyz789"/>
331  </cfdi:Complemento>
332</cfdi:Comprobante>"#
333    }
334
335    #[test]
336    fn test_parse_basic_cfdi() {
337        let cfdi = parse_str(sample_cfdi()).unwrap();
338        assert_eq!(cfdi.version, "4.0");
339        assert_eq!(cfdi.serie.as_deref(), Some("A"));
340        assert_eq!(cfdi.folio.as_deref(), Some("123"));
341        assert_eq!(cfdi.total, "1160.00");
342        assert_eq!(cfdi.sub_total, "1000.00");
343        assert_eq!(cfdi.tipo_de_comprobante, "I");
344        assert_eq!(cfdi.moneda, "MXN");
345    }
346
347    #[test]
348    fn test_parse_emisor() {
349        let cfdi = parse_str(sample_cfdi()).unwrap();
350        assert_eq!(cfdi.emisor.rfc, "AAA010101AAA");
351        assert_eq!(cfdi.emisor.nombre.as_deref(), Some("Empresa Test SA de CV"));
352        assert_eq!(cfdi.emisor.regimen_fiscal, "601");
353    }
354
355    #[test]
356    fn test_parse_receptor() {
357        let cfdi = parse_str(sample_cfdi()).unwrap();
358        assert_eq!(cfdi.receptor.rfc, "BBB020202BBB");
359        assert_eq!(cfdi.receptor.uso_cfdi, "G03");
360    }
361
362    #[test]
363    fn test_parse_conceptos() {
364        let cfdi = parse_str(sample_cfdi()).unwrap();
365        assert_eq!(cfdi.conceptos.len(), 1);
366        let c = &cfdi.conceptos[0];
367        assert_eq!(c.descripcion, "Consultoria");
368        assert_eq!(c.valor_unitario, "1000.00");
369        assert_eq!(c.cantidad, "1");
370    }
371
372    #[test]
373    fn test_parse_impuestos() {
374        let cfdi = parse_str(sample_cfdi()).unwrap();
375        let imp = cfdi.impuestos.as_ref().unwrap();
376        assert_eq!(imp.total_impuestos_trasladados.as_deref(), Some("160.00"));
377        assert_eq!(imp.traslados.len(), 1);
378        assert_eq!(imp.traslados[0].importe.as_deref(), Some("160.00"));
379    }
380
381    #[test]
382    fn test_parse_timbre() {
383        let cfdi = parse_str(sample_cfdi()).unwrap();
384        assert_eq!(
385            cfdi.uuid(),
386            Some("6128396F-0000-1111-2222-333344445555")
387        );
388    }
389
390    #[test]
391    fn test_tipo_comprobante_str() {
392        let cfdi = parse_str(sample_cfdi()).unwrap();
393        assert_eq!(cfdi.tipo_comprobante_str(), "Ingreso");
394    }
395
396    #[test]
397    fn test_invalid_xml() {
398        let result = parse_str("not xml at all");
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_empty_xml() {
404        let result = parse_str("<?xml version=\"1.0\"?><root/>");
405        assert!(result.is_err());
406    }
407}