Skip to main content

fiscal_core/complement/
inutilizacao.rs

1//! Attach SEFAZ inutilizacao response to the request (`<ProcInutNFe>` wrapper).
2
3use crate::FiscalError;
4use crate::status_codes::sefaz_status;
5use crate::xml_utils::extract_xml_tag_value;
6
7use super::helpers::{DEFAULT_VERSION, extract_attribute, extract_tag, join_xml};
8
9/// Attach the SEFAZ inutilizacao response to the request,
10/// producing the `<ProcInutNFe>` wrapper.
11///
12/// Extracts `<inutNFe>` from `request_xml` and `<retInutNFe>` from
13/// `response_xml`, validates that the response status is `102` (voided),
14/// and joins them into a `<ProcInutNFe>` document.
15///
16/// # Errors
17///
18/// Returns [`FiscalError::XmlParsing`] if:
19/// - Either input is empty
20/// - The `<inutNFe>` tag is missing from `request_xml`
21/// - The `<retInutNFe>` tag is missing from `response_xml`
22///
23/// Returns [`FiscalError::SefazRejection`] if the response status is not `102`.
24pub fn attach_inutilizacao(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
25    if request_xml.is_empty() {
26        return Err(FiscalError::XmlParsing(
27            "Inutilizacao request XML is empty".into(),
28        ));
29    }
30    if response_xml.is_empty() {
31        return Err(FiscalError::XmlParsing(
32            "Inutilizacao response XML is empty".into(),
33        ));
34    }
35
36    let inut_content = extract_tag(request_xml, "inutNFe").ok_or_else(|| {
37        FiscalError::XmlParsing("Could not find <inutNFe> tag in request XML".into())
38    })?;
39
40    let ret_inut_content = extract_tag(response_xml, "retInutNFe").ok_or_else(|| {
41        FiscalError::XmlParsing("Could not find <retInutNFe> tag in response XML".into())
42    })?;
43
44    // Validate the response status — must be 102 (voided)
45    let c_stat = extract_xml_tag_value(&ret_inut_content, "cStat").unwrap_or_default();
46    if c_stat != sefaz_status::VOIDED {
47        let x_motivo = extract_xml_tag_value(&ret_inut_content, "xMotivo").unwrap_or_default();
48        return Err(FiscalError::SefazRejection {
49            code: c_stat,
50            message: x_motivo,
51        });
52    }
53
54    // Get version from the inutNFe request tag
55    let version = extract_attribute(&inut_content, "inutNFe", "versao")
56        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
57
58    // Cross-validate request vs response fields (like PHP addInutNFeProtocol)
59    let ret_version = extract_attribute(&ret_inut_content, "retInutNFe", "versao")
60        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
61
62    // Determine whether the request uses CNPJ or CPF
63    let cpf_or_cnpj_tag = if extract_xml_tag_value(&inut_content, "CNPJ").is_some() {
64        "CNPJ"
65    } else {
66        "CPF"
67    };
68
69    let field_pairs: &[(&str, &str, &str)] = &[("versao", &version, &ret_version)];
70    for &(name, req_val, ret_val) in field_pairs {
71        if req_val != ret_val {
72            return Err(FiscalError::XmlParsing(format!(
73                "Inutilização: {name} diverge entre request ({req_val}) e response ({ret_val})"
74            )));
75        }
76    }
77
78    let tag_pairs: &[&str] = &[
79        "tpAmb",
80        "cUF",
81        "ano",
82        cpf_or_cnpj_tag,
83        "mod",
84        "serie",
85        "nNFIni",
86        "nNFFin",
87    ];
88    for tag_name in tag_pairs {
89        let req_val = extract_xml_tag_value(&inut_content, tag_name).unwrap_or_default();
90        let ret_val = extract_xml_tag_value(&ret_inut_content, tag_name).unwrap_or_default();
91        if req_val != ret_val {
92            return Err(FiscalError::XmlParsing(format!(
93                "Inutilização: <{tag_name}> diverge entre request ({req_val}) e response ({ret_val})"
94            )));
95        }
96    }
97
98    Ok(join_xml(
99        &inut_content,
100        &ret_inut_content,
101        "ProcInutNFe",
102        &version,
103    ))
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn attach_inutilizacao_empty_request() {
112        let err = attach_inutilizacao("", "<retInutNFe/>").unwrap_err();
113        assert!(matches!(err, FiscalError::XmlParsing(_)));
114    }
115
116    #[test]
117    fn attach_inutilizacao_empty_response() {
118        let err = attach_inutilizacao("<inutNFe/>", "").unwrap_err();
119        assert!(matches!(err, FiscalError::XmlParsing(_)));
120    }
121
122    #[test]
123    fn attach_inutilizacao_missing_inut_tag() {
124        let err = attach_inutilizacao("<other/>", "<retInutNFe><cStat>102</cStat></retInutNFe>")
125            .unwrap_err();
126        assert!(matches!(err, FiscalError::XmlParsing(_)));
127    }
128
129    #[test]
130    fn attach_inutilizacao_missing_ret_tag() {
131        let err = attach_inutilizacao(r#"<inutNFe versao="4.00"><data/></inutNFe>"#, "<other/>")
132            .unwrap_err();
133        assert!(matches!(err, FiscalError::XmlParsing(_)));
134    }
135
136    #[test]
137    fn attach_inutilizacao_rejected_status() {
138        let err = attach_inutilizacao(
139            r#"<inutNFe versao="4.00"><data/></inutNFe>"#,
140            r#"<retInutNFe><cStat>999</cStat><xMotivo>Erro</xMotivo></retInutNFe>"#,
141        )
142        .unwrap_err();
143        assert!(matches!(err, FiscalError::SefazRejection { .. }));
144    }
145
146    #[test]
147    fn attach_inutilizacao_success() {
148        let result = attach_inutilizacao(
149            r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#,
150            r#"<retInutNFe><cStat>102</cStat><xMotivo>Inutilizacao de numero homologado</xMotivo></retInutNFe>"#,
151        )
152        .unwrap();
153        assert!(result.contains("<ProcInutNFe"));
154        assert!(result.contains("<inutNFe"));
155        assert!(result.contains("<retInutNFe>"));
156    }
157
158    // ── attach_inutilizacao: version mismatch (line 197) ────────────────
159
160    #[test]
161    fn attach_inutilizacao_version_mismatch() {
162        let request = concat!(
163            r#"<inutNFe versao="4.00"><infInut>"#,
164            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
165            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
166            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
167            r#"</infInut></inutNFe>"#
168        );
169        let response = concat!(
170            r#"<retInutNFe versao="3.10"><infInut>"#,
171            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
172            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
173            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
174            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
175            r#"</infInut></retInutNFe>"#
176        );
177        let err = attach_inutilizacao(request, response).unwrap_err();
178        match err {
179            FiscalError::XmlParsing(msg) => {
180                assert!(
181                    msg.contains("versao"),
182                    "Expected version mismatch error, got: {msg}"
183                );
184            }
185            other => panic!("Expected XmlParsing, got {:?}", other),
186        }
187    }
188
189    // ── attach_inutilizacao: tag mismatch (line 217) ────────────────────
190
191    #[test]
192    fn attach_inutilizacao_tag_value_mismatch() {
193        let request = concat!(
194            r#"<inutNFe versao="4.00"><infInut>"#,
195            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
196            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
197            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
198            r#"</infInut></inutNFe>"#
199        );
200        let response = concat!(
201            r#"<retInutNFe versao="4.00"><infInut>"#,
202            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
203            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
204            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>2</serie>"#,
205            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
206            r#"</infInut></retInutNFe>"#
207        );
208        let err = attach_inutilizacao(request, response).unwrap_err();
209        match err {
210            FiscalError::XmlParsing(msg) => {
211                assert!(
212                    msg.contains("serie"),
213                    "Expected serie mismatch error, got: {msg}"
214                );
215            }
216            other => panic!("Expected XmlParsing, got {:?}", other),
217        }
218    }
219}