Skip to main content

fiscal_core/
complement.rs

1use crate::FiscalError;
2use crate::constants::NFE_NAMESPACE;
3use crate::status_codes::{VALID_EVENT_STATUSES, VALID_PROTOCOL_STATUSES, sefaz_status};
4use crate::xml_utils::extract_xml_tag_value;
5
6/// NF-e version used in wrapper elements when no version is found.
7const DEFAULT_VERSION: &str = "4.00";
8
9// ── Public API ──────────────────────────────────────────────────────────────
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        // Fallback: use first available protNFe
78        let single_prot = extract_tag(response_xml, "protNFe").ok_or_else(|| {
79            FiscalError::XmlParsing("Could not find <protNFe> in response XML".into())
80        })?;
81
82        // Validate status on the fallback protNFe
83        let c_stat = extract_xml_tag_value(&single_prot, "cStat").unwrap_or_default();
84        if !VALID_PROTOCOL_STATUSES.contains(&c_stat.as_str()) {
85            let x_motivo = extract_xml_tag_value(&single_prot, "xMotivo").unwrap_or_default();
86            return Err(FiscalError::SefazRejection {
87                code: c_stat,
88                message: x_motivo,
89            });
90        }
91        matched_prot = Some(single_prot);
92    }
93
94    let version = extract_attribute(&nfe_content, "infNFe", "versao")
95        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
96
97    Ok(join_xml(
98        &nfe_content,
99        &matched_prot.unwrap(),
100        "nfeProc",
101        &version,
102    ))
103}
104
105/// Attach the SEFAZ inutilizacao response to the request,
106/// producing the `<ProcInutNFe>` wrapper.
107///
108/// Extracts `<inutNFe>` from `request_xml` and `<retInutNFe>` from
109/// `response_xml`, validates that the response status is `102` (voided),
110/// and joins them into a `<ProcInutNFe>` document.
111///
112/// # Errors
113///
114/// Returns [`FiscalError::XmlParsing`] if:
115/// - Either input is empty
116/// - The `<inutNFe>` tag is missing from `request_xml`
117/// - The `<retInutNFe>` tag is missing from `response_xml`
118///
119/// Returns [`FiscalError::SefazRejection`] if the response status is not `102`.
120pub fn attach_inutilizacao(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
121    if request_xml.is_empty() {
122        return Err(FiscalError::XmlParsing(
123            "Inutilizacao request XML is empty".into(),
124        ));
125    }
126    if response_xml.is_empty() {
127        return Err(FiscalError::XmlParsing(
128            "Inutilizacao response XML is empty".into(),
129        ));
130    }
131
132    let inut_content = extract_tag(request_xml, "inutNFe").ok_or_else(|| {
133        FiscalError::XmlParsing("Could not find <inutNFe> tag in request XML".into())
134    })?;
135
136    let ret_inut_content = extract_tag(response_xml, "retInutNFe").ok_or_else(|| {
137        FiscalError::XmlParsing("Could not find <retInutNFe> tag in response XML".into())
138    })?;
139
140    // Validate the response status — must be 102 (voided)
141    let c_stat = extract_xml_tag_value(&ret_inut_content, "cStat").unwrap_or_default();
142    if c_stat != sefaz_status::VOIDED {
143        let x_motivo = extract_xml_tag_value(&ret_inut_content, "xMotivo").unwrap_or_default();
144        return Err(FiscalError::SefazRejection {
145            code: c_stat,
146            message: x_motivo,
147        });
148    }
149
150    // Get version from the inutNFe request tag
151    let version = extract_attribute(&inut_content, "inutNFe", "versao")
152        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
153
154    Ok(join_xml(
155        &inut_content,
156        &ret_inut_content,
157        "ProcInutNFe",
158        &version,
159    ))
160}
161
162/// Attach an event protocol response to the event request,
163/// producing the `<procEventoNFe>` wrapper.
164///
165/// Extracts `<evento>` from `request_xml` and `<retEvento>` from
166/// `response_xml`, validates the event status, and joins them
167/// into a `<procEventoNFe>` document.
168///
169/// # Errors
170///
171/// Returns [`FiscalError::XmlParsing`] if:
172/// - Either input is empty
173/// - The `<evento>` tag is missing from `request_xml`
174/// - The `<retEvento>` tag is missing from `response_xml`
175///
176/// Returns [`FiscalError::SefazRejection`] if the event status code
177/// is not in [`VALID_EVENT_STATUSES`].
178pub fn attach_event_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
179    if request_xml.is_empty() {
180        return Err(FiscalError::XmlParsing("Event request XML is empty".into()));
181    }
182    if response_xml.is_empty() {
183        return Err(FiscalError::XmlParsing(
184            "Event response XML is empty".into(),
185        ));
186    }
187
188    let evento_content = extract_tag(request_xml, "evento").ok_or_else(|| {
189        FiscalError::XmlParsing("Could not find <evento> tag in request XML".into())
190    })?;
191
192    let ret_evento_content = extract_tag(response_xml, "retEvento").ok_or_else(|| {
193        FiscalError::XmlParsing("Could not find <retEvento> tag in response XML".into())
194    })?;
195
196    // Get version from the evento tag
197    let version = extract_attribute(&evento_content, "evento", "versao")
198        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
199
200    // Validate event status
201    let c_stat = extract_xml_tag_value(&ret_evento_content, "cStat").unwrap_or_default();
202    if !VALID_EVENT_STATUSES.contains(&c_stat.as_str()) {
203        let x_motivo = extract_xml_tag_value(&ret_evento_content, "xMotivo").unwrap_or_default();
204        return Err(FiscalError::SefazRejection {
205            code: c_stat,
206            message: x_motivo,
207        });
208    }
209
210    Ok(join_xml(
211        &evento_content,
212        &ret_evento_content,
213        "procEventoNFe",
214        &version,
215    ))
216}
217
218/// Attach a B2B financial tag to an authorized `<nfeProc>` XML,
219/// wrapping both in a `<nfeProcB2B>` element.
220///
221/// # Arguments
222///
223/// * `nfe_proc_xml` - The authorized nfeProc XML.
224/// * `b2b_xml` - The B2B financial XML (must contain the `tag_b2b` element).
225/// * `tag_b2b` - Optional B2B tag name; defaults to `"NFeB2BFin"`.
226///
227/// # Errors
228///
229/// Returns [`FiscalError::XmlParsing`] if:
230/// - The `nfe_proc_xml` does not contain `<nfeProc>`
231/// - The `b2b_xml` does not contain the expected B2B tag
232/// - Either tag cannot be extracted
233pub fn attach_b2b(
234    nfe_proc_xml: &str,
235    b2b_xml: &str,
236    tag_b2b: Option<&str>,
237) -> Result<String, FiscalError> {
238    let tag_name = tag_b2b.unwrap_or("NFeB2BFin");
239
240    if !nfe_proc_xml.contains("<nfeProc") {
241        return Err(FiscalError::XmlParsing(
242            "XML does not contain <nfeProc> — is this an authorized NFe?".into(),
243        ));
244    }
245
246    let open_check = format!("<{tag_name}");
247    if !b2b_xml.contains(&open_check) {
248        return Err(FiscalError::XmlParsing(format!(
249            "B2B XML does not contain <{tag_name}> tag"
250        )));
251    }
252
253    let nfe_proc_content = extract_tag(nfe_proc_xml, "nfeProc")
254        .ok_or_else(|| FiscalError::XmlParsing("Could not extract <nfeProc> from XML".into()))?;
255
256    let b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
257        FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
258    })?;
259
260    Ok(format!(
261        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
262         <nfeProcB2B>{nfe_proc_content}{b2b_content}</nfeProcB2B>"
263    ))
264}
265
266// ── Internal helpers ────────────────────────────────────────────────────────
267
268/// Join two XML fragments into a versioned namespace wrapper element.
269///
270/// Produces:
271/// ```xml
272/// <?xml version="1.0" encoding="UTF-8"?>
273/// <{node_name} versao="{version}" xmlns="{NFE_NAMESPACE}">
274///   {first}{second}
275/// </{node_name}>
276/// ```
277fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
278    format!(
279        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
280         <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
281         {first}{second}</{node_name}>"
282    )
283}
284
285/// Extract a complete XML tag (outermost match) including attributes and
286/// all nested content. Uses `lastIndexOf`-style search for the closing tag
287/// to handle nested tags of the same name.
288///
289/// Returns `None` if either the opening or closing tag is not found.
290fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
291    // Find the opening tag: <tagName followed by whitespace, >, or /
292    let open_pattern = format!("<{tag_name}");
293    let start = xml.find(&open_pattern)?;
294
295    // Verify that the character after `<tagName` is a valid delimiter
296    // (space, >, /) to avoid matching tags like `<tagNameExtra>`
297    let after_open = start + open_pattern.len();
298    if after_open < xml.len() {
299        let next_char = xml.as_bytes()[after_open];
300        if next_char != b' '
301            && next_char != b'>'
302            && next_char != b'/'
303            && next_char != b'\n'
304            && next_char != b'\r'
305            && next_char != b'\t'
306        {
307            return None;
308        }
309    }
310
311    let close_tag = format!("</{tag_name}>");
312    let close_index = xml.rfind(&close_tag)?;
313
314    Some(xml[start..close_index + close_tag.len()].to_string())
315}
316
317/// Extract all occurrences of a tag from XML. Finds each non-overlapping
318/// `<tagName ...>...</tagName>` in the source string.
319fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
320    let mut results = Vec::new();
321    let open_pattern = format!("<{tag_name}");
322    let close_tag = format!("</{tag_name}>");
323    let mut search_from = 0;
324
325    while search_from < xml.len() {
326        let start = match xml[search_from..].find(&open_pattern) {
327            Some(pos) => search_from + pos,
328            None => break,
329        };
330
331        // Verify delimiter after tag name
332        let after_open = start + open_pattern.len();
333        if after_open < xml.len() {
334            let next_char = xml.as_bytes()[after_open];
335            if next_char != b' '
336                && next_char != b'>'
337                && next_char != b'/'
338                && next_char != b'\n'
339                && next_char != b'\r'
340                && next_char != b'\t'
341            {
342                search_from = after_open;
343                continue;
344            }
345        }
346
347        let end = match xml[start..].find(&close_tag) {
348            Some(pos) => start + pos + close_tag.len(),
349            None => break,
350        };
351
352        results.push(xml[start..end].to_string());
353        search_from = end;
354    }
355
356    results
357}
358
359/// Extract an XML attribute value from a tag. Searches for the tag opening
360/// then finds `attr="value"` within it.
361fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
362    let open = format!("<{tag_name}");
363    let start = xml.find(&open)?;
364
365    // Find the end of the opening tag
366    let tag_end = xml[start..].find('>')? + start;
367    let tag_header = &xml[start..tag_end];
368
369    // Find attr="value" pattern
370    let attr_pattern = format!("{attr_name}=\"");
371    let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
372    let attr_end = tag_header[attr_start..].find('"')? + attr_start;
373
374    Some(tag_header[attr_start..attr_end].to_string())
375}
376
377/// Extract the access key from an `<infNFe Id="NFe...">` attribute.
378/// Returns the 44-digit key (without the "NFe" prefix).
379fn extract_inf_nfe_id(xml: &str) -> Option<String> {
380    let attr_val = extract_attribute(xml, "infNFe", "Id")?;
381    Some(
382        attr_val
383            .strip_prefix("NFe")
384            .unwrap_or(&attr_val)
385            .to_string(),
386    )
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn extract_tag_finds_outermost_match() {
395        let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
396        let result = extract_tag(xml, "NFe").unwrap();
397        assert!(result.starts_with("<NFe"));
398        assert!(result.ends_with("</NFe>"));
399        assert!(result.contains("<inner/>"));
400    }
401
402    #[test]
403    fn extract_tag_returns_none_for_missing_tag() {
404        let xml = "<root><other/></root>";
405        assert!(extract_tag(xml, "NFe").is_none());
406    }
407
408    #[test]
409    fn extract_tag_does_not_match_prefix() {
410        let xml = "<root><NFeExtra>data</NFeExtra></root>";
411        assert!(extract_tag(xml, "NFe").is_none());
412    }
413
414    #[test]
415    fn extract_attribute_works() {
416        let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
417        assert_eq!(
418            extract_attribute(xml, "infNFe", "versao"),
419            Some("4.00".to_string())
420        );
421        assert_eq!(
422            extract_attribute(xml, "infNFe", "Id"),
423            Some("NFe12345".to_string())
424        );
425    }
426
427    #[test]
428    fn extract_all_tags_finds_multiple() {
429        let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
430        let items = extract_all_tags(xml, "item");
431        assert_eq!(items.len(), 3);
432        assert!(items[0].contains("1"));
433        assert!(items[2].contains("3"));
434    }
435
436    #[test]
437    fn join_xml_produces_correct_wrapper() {
438        let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
439        assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
440        assert!(result.contains("<wrapper versao=\"4.00\""));
441        assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
442        assert!(result.ends_with("</wrapper>"));
443    }
444
445    #[test]
446    fn extract_inf_nfe_id_strips_prefix() {
447        let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
448        let key = extract_inf_nfe_id(xml).unwrap();
449        assert_eq!(key, "35260112345678000199650010000000011123456780");
450    }
451}