Skip to main content

fiscal_core/complement/
b2b.rs

1//! Attach B2B financial tag to an authorized `<nfeProc>` XML (`<nfeProcB2B>` wrapper).
2
3use crate::FiscalError;
4
5use super::helpers::{extract_tag, normalize_nfe_proc_attrs, strip_newlines};
6
7/// Attach a B2B financial tag to an authorized `<nfeProc>` XML,
8/// wrapping both in a `<nfeProcB2B>` element.
9///
10/// # Arguments
11///
12/// * `nfe_proc_xml` - The authorized nfeProc XML.
13/// * `b2b_xml` - The B2B financial XML (must contain the `tag_b2b` element).
14/// * `tag_b2b` - Optional B2B tag name; defaults to `"NFeB2BFin"`.
15///
16/// # Errors
17///
18/// Returns [`FiscalError::XmlParsing`] if:
19/// - The `nfe_proc_xml` does not contain `<nfeProc>`
20/// - The `b2b_xml` does not contain the expected B2B tag
21/// - Either tag cannot be extracted
22pub fn attach_b2b(
23    nfe_proc_xml: &str,
24    b2b_xml: &str,
25    tag_b2b: Option<&str>,
26) -> Result<String, FiscalError> {
27    let tag_name = tag_b2b.unwrap_or("NFeB2BFin");
28
29    if !nfe_proc_xml.contains("<nfeProc") {
30        return Err(FiscalError::XmlParsing(
31            "XML does not contain <nfeProc> — is this an authorized NFe?".into(),
32        ));
33    }
34
35    let open_check = format!("<{tag_name}");
36    if !b2b_xml.contains(&open_check) {
37        return Err(FiscalError::XmlParsing(format!(
38            "B2B XML does not contain <{tag_name}> tag"
39        )));
40    }
41
42    let nfe_proc_content = extract_tag(nfe_proc_xml, "nfeProc")
43        .ok_or_else(|| FiscalError::XmlParsing("Could not extract <nfeProc> from XML".into()))?;
44
45    // PHP DOMDocument re-serializes <nfeProc> with xmlns before versao
46    // (DOM canonical attribute ordering). We must match this behavior.
47    let nfe_proc_normalized = normalize_nfe_proc_attrs(&nfe_proc_content);
48
49    let b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
50        FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
51    })?;
52
53    let raw = format!(
54        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
55         <nfeProcB2B>{nfe_proc_normalized}{b2b_content}</nfeProcB2B>"
56    );
57
58    // PHP Complements::b2bTag line 79 does:
59    //   str_replace(array("\n", "\r", "\s"), '', $nfeb2bXML)
60    // This removes newlines/carriage-returns (and the literal "\s" which is
61    // a PHP quirk — "\s" inside single quotes is just the characters \ and s,
62    // but that string never appears in XML anyway).
63    let cleaned = strip_newlines(&raw);
64    Ok(cleaned)
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    // ── attach_b2b tests ────────────────────────────────────────────
72
73    #[test]
74    fn attach_b2b_no_nfe_proc() {
75        let err = attach_b2b("<NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
76        assert!(matches!(err, FiscalError::XmlParsing(_)));
77    }
78
79    #[test]
80    fn attach_b2b_no_b2b_tag() {
81        let err = attach_b2b("<nfeProc><NFe/></nfeProc>", "<other>data</other>", None).unwrap_err();
82        assert!(matches!(err, FiscalError::XmlParsing(_)));
83    }
84
85    #[test]
86    fn attach_b2b_extract_failure() {
87        // nfeProc without closing tag won't extract
88        let err = attach_b2b("<nfeProc><NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
89        assert!(matches!(err, FiscalError::XmlParsing(_)));
90    }
91
92    #[test]
93    fn attach_b2b_success() {
94        let result = attach_b2b(
95            "<nfeProc><NFe/><protNFe/></nfeProc>",
96            "<NFeB2BFin><tag>data</tag></NFeB2BFin>",
97            None,
98        )
99        .unwrap();
100        assert!(result.contains("<nfeProcB2B>"));
101        assert!(result.contains("<nfeProc>"));
102        assert!(result.contains("<NFeB2BFin>"));
103    }
104
105    #[test]
106    fn attach_b2b_custom_tag() {
107        let result = attach_b2b(
108            "<nfeProc><NFe/><protNFe/></nfeProc>",
109            "<CustomB2B><tag>data</tag></CustomB2B>",
110            Some("CustomB2B"),
111        )
112        .unwrap();
113        assert!(result.contains("<CustomB2B>"));
114    }
115
116    // ── attach_b2b whitespace stripping tests ───────────────────────────
117
118    #[test]
119    fn attach_b2b_strips_newlines() {
120        let nfe_proc = "<nfeProc versao=\"4.00\">\n<NFe/>\n<protNFe/>\n</nfeProc>";
121        let b2b = "<NFeB2BFin>\n<data>test</data>\n</NFeB2BFin>";
122        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
123        assert!(!result.contains('\n'), "Result should not contain newlines");
124        assert!(
125            !result.contains('\r'),
126            "Result should not contain carriage returns"
127        );
128        assert!(result.contains("<nfeProcB2B>"));
129        assert!(result.contains("<NFeB2BFin>"));
130    }
131
132    #[test]
133    fn attach_b2b_strips_carriage_returns() {
134        let nfe_proc = "<nfeProc versao=\"4.00\">\r\n<NFe/>\r\n</nfeProc>";
135        let b2b = "<NFeB2BFin><data>test</data></NFeB2BFin>";
136        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
137        assert!(!result.contains('\r'));
138        assert!(!result.contains('\n'));
139    }
140
141    // ── attach_b2b: extract_tag for b2b content (line 348) ──────────────
142
143    #[test]
144    fn attach_b2b_extract_tag_coverage() {
145        let nfe_proc = concat!(
146            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
147            r#"<NFe><infNFe/></NFe><protNFe><infProt/></protNFe>"#,
148            r#"</nfeProc>"#
149        );
150        let b2b = r#"<NFeB2BFin versao="1.00"><dados>value</dados></NFeB2BFin>"#;
151        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
152        assert!(result.contains("<nfeProcB2B>"));
153        assert!(result.contains("<dados>value</dados>"));
154    }
155}