Skip to main content

fiscal_core/complement/
protocol.rs

1//! Attach SEFAZ authorization protocol to a signed NFe XML (`<nfeProc>` wrapper).
2
3use crate::FiscalError;
4use crate::status_codes::VALID_PROTOCOL_STATUSES;
5use crate::xml_utils::extract_xml_tag_value;
6
7use super::helpers::{
8    DEFAULT_VERSION, extract_all_tags, extract_attribute, extract_inf_nfe_id, extract_tag, join_xml,
9};
10
11/// Attach the SEFAZ authorization protocol to a signed NFe XML,
12/// producing the `<nfeProc>` wrapper required for storage and DANFE.
13///
14/// The function extracts the `<NFe>` from `request_xml` and the matching
15/// `<protNFe>` from `response_xml`, validates the protocol status, and
16/// joins them into a single `<nfeProc>` document.
17///
18/// If the response contains multiple `<protNFe>` nodes (batch response),
19/// the function attempts to match by digest value and access key. When no
20/// exact match is found it falls back to the first available `<protNFe>`.
21///
22/// # Errors
23///
24/// Returns [`FiscalError::XmlParsing`] if:
25/// - Either input is empty
26/// - The `<NFe>` tag is missing from `request_xml`
27/// - No `<protNFe>` can be found in `response_xml`
28///
29/// Returns [`FiscalError::SefazRejection`] if the protocol status code
30/// is not in [`VALID_PROTOCOL_STATUSES`].
31pub fn attach_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
32    if request_xml.is_empty() {
33        return Err(FiscalError::XmlParsing("Request XML (NFe) is empty".into()));
34    }
35    if response_xml.is_empty() {
36        return Err(FiscalError::XmlParsing(
37            "Response XML (protocol) is empty".into(),
38        ));
39    }
40
41    let nfe_content = extract_tag(request_xml, "NFe")
42        .ok_or_else(|| FiscalError::XmlParsing("Could not find <NFe> tag in request XML".into()))?;
43
44    // Extract digest and access key from the NFe for matching
45    let digest_nfe = extract_xml_tag_value(request_xml, "DigestValue");
46    let access_key = extract_inf_nfe_id(request_xml);
47
48    // Try to find a matching protNFe by digest + access key
49    let mut matched_prot: Option<String> = None;
50
51    let prot_nodes = extract_all_tags(response_xml, "protNFe");
52
53    for prot in &prot_nodes {
54        let dig_val = extract_xml_tag_value(prot, "digVal");
55        let ch_nfe = extract_xml_tag_value(prot, "chNFe");
56
57        if let (Some(dn), Some(dv)) = (&digest_nfe, &dig_val) {
58            if let (Some(ak), Some(cn)) = (&access_key, &ch_nfe) {
59                if dn == dv && ak == cn {
60                    // Exact match — validate status
61                    let c_stat = extract_xml_tag_value(prot, "cStat").unwrap_or_default();
62                    if !VALID_PROTOCOL_STATUSES.contains(&c_stat.as_str()) {
63                        let x_motivo = extract_xml_tag_value(prot, "xMotivo").unwrap_or_default();
64                        return Err(FiscalError::SefazRejection {
65                            code: c_stat,
66                            message: x_motivo,
67                        });
68                    }
69                    matched_prot = Some(prot.clone());
70                    break;
71                }
72            }
73        }
74    }
75
76    if matched_prot.is_none() {
77        // Check if any protNFe had a digVal (but didn't match)
78        let mut found_dig_val = false;
79        for prot in &prot_nodes {
80            if extract_xml_tag_value(prot, "digVal").is_some() {
81                found_dig_val = true;
82                break;
83            }
84        }
85
86        if !prot_nodes.is_empty() && !found_dig_val {
87            // digVal is null in the response — error 18 per PHP
88            let first_prot = &prot_nodes[0];
89            let c_stat = extract_xml_tag_value(first_prot, "cStat").unwrap_or_default();
90            let x_motivo = extract_xml_tag_value(first_prot, "xMotivo").unwrap_or_default();
91            let msg = format!("digVal ausente na resposta SEFAZ: [{c_stat}] {x_motivo}");
92            return Err(FiscalError::SefazRejection {
93                code: c_stat,
94                message: msg,
95            });
96        }
97
98        if found_dig_val {
99            // digVal exists but didn't match our DigestValue — error 5 per PHP
100            let key_info = access_key.as_deref().unwrap_or("unknown");
101            return Err(FiscalError::XmlParsing(format!(
102                "Os digest são diferentes [{key_info}]"
103            )));
104        }
105
106        // No protNFe at all
107        let single_prot = extract_tag(response_xml, "protNFe").ok_or_else(|| {
108            FiscalError::XmlParsing("Could not find <protNFe> in response XML".into())
109        })?;
110
111        // Validate status on the fallback protNFe
112        let c_stat = extract_xml_tag_value(&single_prot, "cStat").unwrap_or_default();
113        if !VALID_PROTOCOL_STATUSES.contains(&c_stat.as_str()) {
114            let x_motivo = extract_xml_tag_value(&single_prot, "xMotivo").unwrap_or_default();
115            return Err(FiscalError::SefazRejection {
116                code: c_stat,
117                message: x_motivo,
118            });
119        }
120        matched_prot = Some(single_prot);
121    }
122
123    let version = extract_attribute(&nfe_content, "infNFe", "versao")
124        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
125
126    Ok(join_xml(
127        &nfe_content,
128        &matched_prot.unwrap(),
129        "nfeProc",
130        &version,
131    ))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn attach_protocol_empty_request_xml() {
140        let err = attach_protocol("", "<protNFe/>").unwrap_err();
141        assert!(matches!(err, FiscalError::XmlParsing(_)));
142    }
143
144    #[test]
145    fn attach_protocol_empty_response_xml() {
146        let err = attach_protocol("<NFe/>", "").unwrap_err();
147        assert!(matches!(err, FiscalError::XmlParsing(_)));
148    }
149
150    #[test]
151    fn attach_protocol_matching_digest_and_key() {
152        let request = concat!(
153            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
154            r#"<ide/></infNFe>"#,
155            r#"<Signature><SignedInfo/><SignatureValue/>"#,
156            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
157            r#"</NFe>"#
158        );
159        let response = concat!(
160            r#"<protNFe versao="4.00"><infProt>"#,
161            r#"<digVal>abc123</digVal>"#,
162            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
163            r#"<cStat>100</cStat>"#,
164            r#"<xMotivo>Autorizado</xMotivo>"#,
165            r#"</infProt></protNFe>"#
166        );
167        let result = attach_protocol(request, response).unwrap();
168        assert!(result.contains("<nfeProc"));
169        assert!(result.contains("</nfeProc>"));
170        assert!(result.contains("<NFe>"));
171        assert!(result.contains("<protNFe"));
172    }
173
174    #[test]
175    fn attach_protocol_rejected_status_in_exact_match() {
176        let request = concat!(
177            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
178            r#"<ide/></infNFe>"#,
179            r#"<Signature><SignedInfo/><SignatureValue/>"#,
180            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
181            r#"</NFe>"#
182        );
183        let response = concat!(
184            r#"<protNFe versao="4.00"><infProt>"#,
185            r#"<digVal>abc123</digVal>"#,
186            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
187            r#"<cStat>999</cStat>"#,
188            r#"<xMotivo>Rejeitada</xMotivo>"#,
189            r#"</infProt></protNFe>"#
190        );
191        let err = attach_protocol(request, response).unwrap_err();
192        assert!(matches!(err, FiscalError::SefazRejection { .. }));
193    }
194
195    #[test]
196    fn attach_protocol_fallback_rejected_status() {
197        // No digest match, falls back to first protNFe which is rejected
198        let request = concat!(
199            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
200            r#"<ide/></infNFe></NFe>"#
201        );
202        let response = concat!(
203            r#"<protNFe versao="4.00"><infProt>"#,
204            r#"<cStat>999</cStat>"#,
205            r#"<xMotivo>Rejeitada</xMotivo>"#,
206            r#"</infProt></protNFe>"#
207        );
208        let err = attach_protocol(request, response).unwrap_err();
209        assert!(matches!(err, FiscalError::SefazRejection { .. }));
210    }
211
212    // ── attach_protocol: fallback protNFe with invalid cStat (lines 112-116) ──
213
214    #[test]
215    fn attach_protocol_fallback_prot_invalid_status() {
216        // Request with NFe, digest, access key
217        let request = concat!(
218            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
219            r#"<DigestValue>abc123</DigestValue>"#,
220            r#"</infNFe></NFe>"#
221        );
222        // Response with single protNFe that has NO digVal (trigger fallback),
223        // but status is invalid
224        let response = concat!(
225            r#"<protNFe versao="4.00"><infProt>"#,
226            r#"<cStat>999</cStat>"#,
227            r#"<xMotivo>Rejeitado</xMotivo>"#,
228            r#"</infProt></protNFe>"#
229        );
230        let err = attach_protocol(request, response).unwrap_err();
231        match err {
232            FiscalError::SefazRejection { code, .. } => assert_eq!(code, "999"),
233            other => panic!("Expected SefazRejection, got {:?}", other),
234        }
235    }
236}