Skip to main content

cfdi_cli/
validator.rs

1use crate::models::Comprobante;
2
3#[derive(Debug)]
4pub struct ValidationResult {
5    pub valid: bool,
6    pub errors: Vec<String>,
7    pub warnings: Vec<String>,
8}
9
10pub fn validate(cfdi: &Comprobante) -> ValidationResult {
11    let mut errors = Vec::new();
12    let mut warnings = Vec::new();
13
14    // Version check
15    if cfdi.version != "4.0" && cfdi.version != "3.3" {
16        errors.push(format!("Version no soportada: {}", cfdi.version));
17    }
18
19    // Required fields
20    if cfdi.fecha.is_empty() {
21        errors.push("Fecha es requerida".to_string());
22    }
23    if cfdi.sub_total.is_empty() {
24        errors.push("SubTotal es requerido".to_string());
25    }
26    if cfdi.total.is_empty() {
27        errors.push("Total es requerido".to_string());
28    }
29    if cfdi.tipo_de_comprobante.is_empty() {
30        errors.push("TipoDeComprobante es requerido".to_string());
31    }
32    if cfdi.lugar_expedicion.is_empty() {
33        errors.push("LugarExpedicion es requerido".to_string());
34    }
35    if cfdi.sello.is_empty() {
36        errors.push("Sello digital es requerido".to_string());
37    }
38    if cfdi.certificado.is_empty() {
39        errors.push("Certificado es requerido".to_string());
40    }
41    if cfdi.no_certificado.is_empty() {
42        errors.push("NoCertificado es requerido".to_string());
43    }
44
45    // Emisor
46    if cfdi.emisor.rfc.is_empty() {
47        errors.push("Emisor RFC es requerido".to_string());
48    } else if !is_valid_rfc(&cfdi.emisor.rfc) {
49        errors.push(format!("Emisor RFC invalido: {}", cfdi.emisor.rfc));
50    }
51    if cfdi.emisor.regimen_fiscal.is_empty() {
52        errors.push("Emisor RegimenFiscal es requerido".to_string());
53    }
54
55    // Receptor
56    if cfdi.receptor.rfc.is_empty() {
57        errors.push("Receptor RFC es requerido".to_string());
58    } else if !is_valid_rfc(&cfdi.receptor.rfc) {
59        warnings.push(format!(
60            "Receptor RFC podria ser invalido: {}",
61            cfdi.receptor.rfc
62        ));
63    }
64    if cfdi.receptor.uso_cfdi.is_empty() {
65        errors.push("Receptor UsoCFDI es requerido".to_string());
66    }
67
68    // Conceptos
69    if cfdi.conceptos.is_empty() {
70        errors.push("Debe tener al menos un Concepto".to_string());
71    }
72    for (i, concepto) in cfdi.conceptos.iter().enumerate() {
73        let prefix = format!("Concepto[{}]", i + 1);
74        if concepto.clave_prod_serv.is_empty() {
75            errors.push(format!("{prefix}: ClaveProdServ es requerido"));
76        }
77        if concepto.cantidad.is_empty() {
78            errors.push(format!("{prefix}: Cantidad es requerida"));
79        }
80        if concepto.descripcion.is_empty() {
81            errors.push(format!("{prefix}: Descripcion es requerida"));
82        }
83        if concepto.valor_unitario.is_empty() {
84            errors.push(format!("{prefix}: ValorUnitario es requerido"));
85        }
86        if concepto.importe.is_empty() {
87            errors.push(format!("{prefix}: Importe es requerido"));
88        }
89    }
90
91    // Total arithmetic check
92    if let (Ok(sub), Ok(total)) = (
93        cfdi.sub_total.parse::<f64>(),
94        cfdi.total.parse::<f64>(),
95    ) {
96        let descuento: f64 = cfdi
97            .descuento
98            .as_deref()
99            .and_then(|d| d.parse().ok())
100            .unwrap_or(0.0);
101        let trasladados: f64 = cfdi
102            .impuestos
103            .as_ref()
104            .and_then(|i| i.total_impuestos_trasladados.as_deref())
105            .and_then(|t| t.parse().ok())
106            .unwrap_or(0.0);
107        let retenidos: f64 = cfdi
108            .impuestos
109            .as_ref()
110            .and_then(|i| i.total_impuestos_retenidos.as_deref())
111            .and_then(|t| t.parse().ok())
112            .unwrap_or(0.0);
113
114        let expected = sub - descuento + trasladados - retenidos;
115        if (expected - total).abs() > 0.01 {
116            warnings.push(format!(
117                "Total ({total}) no coincide con calculo esperado ({expected:.2}): SubTotal({sub}) - Descuento({descuento}) + Trasladados({trasladados}) - Retenidos({retenidos})"
118            ));
119        }
120    }
121
122    // Timbre fiscal
123    if cfdi.uuid().is_none() {
124        warnings.push("No tiene TimbreFiscalDigital (UUID)".to_string());
125    }
126
127    // TipoDeComprobante valid values
128    if !["I", "E", "T", "P", "N"].contains(&cfdi.tipo_de_comprobante.as_str()) {
129        errors.push(format!(
130            "TipoDeComprobante invalido: {}",
131            cfdi.tipo_de_comprobante
132        ));
133    }
134
135    ValidationResult {
136        valid: errors.is_empty(),
137        errors,
138        warnings,
139    }
140}
141
142fn is_valid_rfc(rfc: &str) -> bool {
143    let len = rfc.len();
144    // Persona moral: 12 chars, persona fisica: 13 chars
145    // Generic receptor: XAXX010101000, foreign: XEXX010101000
146    (len == 12 || len == 13) && rfc.chars().all(|c| c.is_ascii_alphanumeric())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::parser::parse_str;
153
154    fn valid_cfdi_xml() -> &'static str {
155        r#"<?xml version="1.0" encoding="utf-8"?>
156<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital" Version="4.0" Fecha="2024-01-15T10:30:00" Sello="abc123" NoCertificado="00001000000504465028" Certificado="CERT123" SubTotal="1000.00" Total="1160.00" TipoDeComprobante="I" FormaPago="03" Moneda="MXN" LugarExpedicion="44100">
157  <cfdi:Emisor Rfc="AAA010101AAA" Nombre="Test" RegimenFiscal="601"/>
158  <cfdi:Receptor Rfc="BBB020202BBB" Nombre="Cliente" UsoCFDI="G03"/>
159  <cfdi:Conceptos>
160    <cfdi:Concepto ClaveProdServ="84111506" Cantidad="1" ClaveUnidad="E48" Descripcion="Servicio" ValorUnitario="1000.00" Importe="1000.00" ObjetoImp="02"/>
161  </cfdi:Conceptos>
162  <cfdi:Impuestos TotalImpuestosTrasladados="160.00">
163    <cfdi:Traslados>
164      <cfdi:Traslado Base="1000.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="160.00"/>
165    </cfdi:Traslados>
166  </cfdi:Impuestos>
167  <cfdi:Complemento>
168    <tfd:TimbreFiscalDigital UUID="6128396F-0000-1111-2222-333344445555" FechaTimbrado="2024-01-15T10:31:00" SelloCFD="abc123" NoCertificadoSAT="001" SelloSAT="xyz"/>
169  </cfdi:Complemento>
170</cfdi:Comprobante>"#
171    }
172
173    #[test]
174    fn test_valid_cfdi() {
175        let cfdi = parse_str(valid_cfdi_xml()).unwrap();
176        let result = validate(&cfdi);
177        assert!(result.valid, "Errors: {:?}", result.errors);
178    }
179
180    #[test]
181    fn test_rfc_validation() {
182        assert!(is_valid_rfc("AAA010101AAA")); // moral 12
183        assert!(is_valid_rfc("AABB010101CCC")); // fisica 13 (hypothetical)
184        assert!(!is_valid_rfc("SHORT"));
185        assert!(!is_valid_rfc("AAA-010101-AAA")); // has dashes
186    }
187}