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
42fn 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
57fn 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
63fn 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
69fn 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 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}