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/// Cancellation event type code (`110111`).
267const EVT_CANCELA: &str = "110111";
268/// Cancellation by substitution event type code (`110112`).
269const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
270
271/// Valid status codes for cancellation event matching.
272///
273/// - `135` — Event registered and linked
274/// - `136` — Event registered but not linked
275/// - `155` — Already cancelled (late)
276const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
277
278/// Attach a cancellation event response to an authorized `<nfeProc>` XML,
279/// marking the NF-e as locally cancelled.
280///
281/// This mirrors the PHP `Complements::cancelRegister()` method. The function
282/// searches the `cancel_event_xml` for `<retEvento>` elements whose:
283/// - `cStat` is in `[135, 136, 155]` (valid cancellation statuses)
284/// - `tpEvento` is `110111` (cancellation) or `110112` (cancellation by substitution)
285/// - `chNFe` matches the access key in the authorized NF-e's `<protNFe>`
286///
287/// When a matching `<retEvento>` is found, it is appended inside the
288/// `<nfeProc>` element (before the closing `</nfeProc>` tag).
289///
290/// If no matching cancellation event is found, the original NF-e XML is
291/// returned unchanged (same behavior as the PHP implementation).
292///
293/// # Arguments
294///
295/// * `nfe_proc_xml` - The authorized NF-e XML containing `<nfeProc>` with `<protNFe>`.
296/// * `cancel_event_xml` - The SEFAZ cancellation event response XML containing `<retEvento>`.
297///
298/// # Errors
299///
300/// Returns [`FiscalError::XmlParsing`] if:
301/// - The `nfe_proc_xml` does not contain `<protNFe>` (not an authorized NF-e)
302/// - The `<protNFe>` does not contain `<chNFe>`
303pub fn attach_cancellation(
304    nfe_proc_xml: &str,
305    cancel_event_xml: &str,
306) -> Result<String, FiscalError> {
307    // Validate the NF-e has a protNFe with a chNFe
308    let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
309        FiscalError::XmlParsing(
310            "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
311        )
312    })?;
313
314    let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
315        .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
316
317    // Search for matching retEvento in the cancellation XML
318    let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
319
320    for ret_evento in &ret_eventos {
321        let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
322            Some(v) => v,
323            None => continue,
324        };
325        let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
326            Some(v) => v,
327            None => continue,
328        };
329        let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
330            Some(v) => v,
331            None => continue,
332        };
333
334        if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
335            && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
336            && ch_nfe_evento == ch_nfe
337        {
338            // Insert the retEvento before </nfeProc>
339            let close_tag = "</nfeProc>";
340            if let Some(pos) = nfe_proc_xml.rfind(close_tag) {
341                let mut result = String::with_capacity(nfe_proc_xml.len() + ret_evento.len());
342                result.push_str(&nfe_proc_xml[..pos]);
343                result.push_str(ret_evento);
344                result.push_str(close_tag);
345                return Ok(result);
346            }
347            // If no </nfeProc>, just append to the end (best effort)
348            break;
349        }
350    }
351
352    // No matching cancellation event found — return original XML unchanged
353    Ok(nfe_proc_xml.to_string())
354}
355
356// ── Internal helpers ────────────────────────────────────────────────────────
357
358/// Join two XML fragments into a versioned namespace wrapper element.
359///
360/// Produces:
361/// ```xml
362/// <?xml version="1.0" encoding="UTF-8"?>
363/// <{node_name} versao="{version}" xmlns="{NFE_NAMESPACE}">
364///   {first}{second}
365/// </{node_name}>
366/// ```
367fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
368    format!(
369        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
370         <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
371         {first}{second}</{node_name}>"
372    )
373}
374
375/// Extract a complete XML tag (outermost match) including attributes and
376/// all nested content. Uses `lastIndexOf`-style search for the closing tag
377/// to handle nested tags of the same name.
378///
379/// Returns `None` if either the opening or closing tag is not found.
380fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
381    // Find the opening tag: <tagName followed by whitespace, >, or /
382    let open_pattern = format!("<{tag_name}");
383    let start = xml.find(&open_pattern)?;
384
385    // Verify that the character after `<tagName` is a valid delimiter
386    // (space, >, /) to avoid matching tags like `<tagNameExtra>`
387    let after_open = start + open_pattern.len();
388    if after_open < xml.len() {
389        let next_char = xml.as_bytes()[after_open];
390        if next_char != b' '
391            && next_char != b'>'
392            && next_char != b'/'
393            && next_char != b'\n'
394            && next_char != b'\r'
395            && next_char != b'\t'
396        {
397            return None;
398        }
399    }
400
401    let close_tag = format!("</{tag_name}>");
402    let close_index = xml.rfind(&close_tag)?;
403
404    Some(xml[start..close_index + close_tag.len()].to_string())
405}
406
407/// Extract all occurrences of a tag from XML. Finds each non-overlapping
408/// `<tagName ...>...</tagName>` in the source string.
409fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
410    let mut results = Vec::new();
411    let open_pattern = format!("<{tag_name}");
412    let close_tag = format!("</{tag_name}>");
413    let mut search_from = 0;
414
415    while search_from < xml.len() {
416        let start = match xml[search_from..].find(&open_pattern) {
417            Some(pos) => search_from + pos,
418            None => break,
419        };
420
421        // Verify delimiter after tag name
422        let after_open = start + open_pattern.len();
423        if after_open < xml.len() {
424            let next_char = xml.as_bytes()[after_open];
425            if next_char != b' '
426                && next_char != b'>'
427                && next_char != b'/'
428                && next_char != b'\n'
429                && next_char != b'\r'
430                && next_char != b'\t'
431            {
432                search_from = after_open;
433                continue;
434            }
435        }
436
437        let end = match xml[start..].find(&close_tag) {
438            Some(pos) => start + pos + close_tag.len(),
439            None => break,
440        };
441
442        results.push(xml[start..end].to_string());
443        search_from = end;
444    }
445
446    results
447}
448
449/// Extract an XML attribute value from a tag. Searches for the tag opening
450/// then finds `attr="value"` within it.
451fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
452    let open = format!("<{tag_name}");
453    let start = xml.find(&open)?;
454
455    // Find the end of the opening tag
456    let tag_end = xml[start..].find('>')? + start;
457    let tag_header = &xml[start..tag_end];
458
459    // Find attr="value" pattern
460    let attr_pattern = format!("{attr_name}=\"");
461    let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
462    let attr_end = tag_header[attr_start..].find('"')? + attr_start;
463
464    Some(tag_header[attr_start..attr_end].to_string())
465}
466
467/// Extract the access key from an `<infNFe Id="NFe...">` attribute.
468/// Returns the 44-digit key (without the "NFe" prefix).
469fn extract_inf_nfe_id(xml: &str) -> Option<String> {
470    let attr_val = extract_attribute(xml, "infNFe", "Id")?;
471    Some(
472        attr_val
473            .strip_prefix("NFe")
474            .unwrap_or(&attr_val)
475            .to_string(),
476    )
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn extract_tag_finds_outermost_match() {
485        let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
486        let result = extract_tag(xml, "NFe").unwrap();
487        assert!(result.starts_with("<NFe"));
488        assert!(result.ends_with("</NFe>"));
489        assert!(result.contains("<inner/>"));
490    }
491
492    #[test]
493    fn extract_tag_returns_none_for_missing_tag() {
494        let xml = "<root><other/></root>";
495        assert!(extract_tag(xml, "NFe").is_none());
496    }
497
498    #[test]
499    fn extract_tag_does_not_match_prefix() {
500        let xml = "<root><NFeExtra>data</NFeExtra></root>";
501        assert!(extract_tag(xml, "NFe").is_none());
502    }
503
504    #[test]
505    fn extract_attribute_works() {
506        let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
507        assert_eq!(
508            extract_attribute(xml, "infNFe", "versao"),
509            Some("4.00".to_string())
510        );
511        assert_eq!(
512            extract_attribute(xml, "infNFe", "Id"),
513            Some("NFe12345".to_string())
514        );
515    }
516
517    #[test]
518    fn extract_all_tags_finds_multiple() {
519        let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
520        let items = extract_all_tags(xml, "item");
521        assert_eq!(items.len(), 3);
522        assert!(items[0].contains("1"));
523        assert!(items[2].contains("3"));
524    }
525
526    #[test]
527    fn join_xml_produces_correct_wrapper() {
528        let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
529        assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
530        assert!(result.contains("<wrapper versao=\"4.00\""));
531        assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
532        assert!(result.ends_with("</wrapper>"));
533    }
534
535    #[test]
536    fn extract_inf_nfe_id_strips_prefix() {
537        let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
538        let key = extract_inf_nfe_id(xml).unwrap();
539        assert_eq!(key, "35260112345678000199650010000000011123456780");
540    }
541
542    // ── attach_cancellation tests ─────────────────────────────────────
543
544    #[test]
545    fn attach_cancellation_appends_matching_ret_evento() {
546        let nfe_proc = concat!(
547            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
548            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
549            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
550            r#"<ide/></infNFe></NFe>"#,
551            r#"<protNFe versao="4.00"><infProt>"#,
552            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
553            r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
554            r#"</infProt></protNFe>"#,
555            r#"</nfeProc>"#
556        );
557
558        let cancel_xml = concat!(
559            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
560            r#"<cStat>135</cStat>"#,
561            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
562            r#"<tpEvento>110111</tpEvento>"#,
563            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
564            r#"<nProt>135220000009999</nProt>"#,
565            r#"</infEvento></retEvento></retEnvEvento>"#
566        );
567
568        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
569
570        // Must contain the retEvento inside nfeProc
571        assert!(
572            result.contains("<retEvento"),
573            "Result should contain <retEvento>"
574        );
575        assert!(
576            result.contains("<tpEvento>110111</tpEvento>"),
577            "Result should contain cancellation event type"
578        );
579        // The retEvento should appear before </nfeProc>
580        let ret_pos = result.find("<retEvento").unwrap();
581        let close_pos = result.rfind("</nfeProc>").unwrap();
582        assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
583        // Original content should be preserved
584        assert!(result.contains("<protNFe"));
585        assert!(result.contains("<NFe>"));
586    }
587
588    #[test]
589    fn attach_cancellation_ignores_non_matching_ch_nfe() {
590        let nfe_proc = concat!(
591            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
592            r#"<NFe/>"#,
593            r#"<protNFe versao="4.00"><infProt>"#,
594            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
595            r#"<cStat>100</cStat>"#,
596            r#"</infProt></protNFe>"#,
597            r#"</nfeProc>"#
598        );
599
600        let cancel_xml = concat!(
601            r#"<retEvento versao="1.00"><infEvento>"#,
602            r#"<cStat>135</cStat>"#,
603            r#"<tpEvento>110111</tpEvento>"#,
604            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
605            r#"<nProt>135220000009999</nProt>"#,
606            r#"</infEvento></retEvento>"#
607        );
608
609        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
610        // Should return original unchanged — no matching chNFe
611        assert_eq!(result, nfe_proc);
612    }
613
614    #[test]
615    fn attach_cancellation_ignores_wrong_tp_evento() {
616        let nfe_proc = concat!(
617            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
618            r#"<NFe/>"#,
619            r#"<protNFe versao="4.00"><infProt>"#,
620            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
621            r#"<cStat>100</cStat>"#,
622            r#"</infProt></protNFe>"#,
623            r#"</nfeProc>"#
624        );
625
626        let cancel_xml = concat!(
627            r#"<retEvento versao="1.00"><infEvento>"#,
628            r#"<cStat>135</cStat>"#,
629            r#"<tpEvento>110110</tpEvento>"#, // CCe, not cancellation
630            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
631            r#"<nProt>135220000009999</nProt>"#,
632            r#"</infEvento></retEvento>"#
633        );
634
635        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
636        // Should return original unchanged — wrong tpEvento
637        assert_eq!(result, nfe_proc);
638    }
639
640    #[test]
641    fn attach_cancellation_ignores_rejected_status() {
642        let nfe_proc = concat!(
643            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
644            r#"<NFe/>"#,
645            r#"<protNFe versao="4.00"><infProt>"#,
646            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
647            r#"<cStat>100</cStat>"#,
648            r#"</infProt></protNFe>"#,
649            r#"</nfeProc>"#
650        );
651
652        let cancel_xml = concat!(
653            r#"<retEvento versao="1.00"><infEvento>"#,
654            r#"<cStat>573</cStat>"#, // Rejected status
655            r#"<tpEvento>110111</tpEvento>"#,
656            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
657            r#"<nProt>135220000009999</nProt>"#,
658            r#"</infEvento></retEvento>"#
659        );
660
661        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
662        // Should return original unchanged — rejected status
663        assert_eq!(result, nfe_proc);
664    }
665
666    #[test]
667    fn attach_cancellation_accepts_status_155() {
668        let nfe_proc = concat!(
669            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
670            r#"<NFe/>"#,
671            r#"<protNFe versao="4.00"><infProt>"#,
672            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
673            r#"<cStat>100</cStat>"#,
674            r#"</infProt></protNFe>"#,
675            r#"</nfeProc>"#
676        );
677
678        let cancel_xml = concat!(
679            r#"<retEvento versao="1.00"><infEvento>"#,
680            r#"<cStat>155</cStat>"#,
681            r#"<tpEvento>110111</tpEvento>"#,
682            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
683            r#"<nProt>135220000009999</nProt>"#,
684            r#"</infEvento></retEvento>"#
685        );
686
687        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
688        assert!(result.contains("<retEvento"));
689    }
690
691    #[test]
692    fn attach_cancellation_accepts_substituicao_110112() {
693        let nfe_proc = concat!(
694            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
695            r#"<NFe/>"#,
696            r#"<protNFe versao="4.00"><infProt>"#,
697            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
698            r#"<cStat>100</cStat>"#,
699            r#"</infProt></protNFe>"#,
700            r#"</nfeProc>"#
701        );
702
703        let cancel_xml = concat!(
704            r#"<retEvento versao="1.00"><infEvento>"#,
705            r#"<cStat>135</cStat>"#,
706            r#"<tpEvento>110112</tpEvento>"#,
707            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
708            r#"<nProt>135220000009999</nProt>"#,
709            r#"</infEvento></retEvento>"#
710        );
711
712        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
713        assert!(
714            result.contains("<tpEvento>110112</tpEvento>"),
715            "Should accept cancellation by substitution"
716        );
717    }
718
719    #[test]
720    fn attach_cancellation_rejects_missing_prot_nfe() {
721        let nfe_xml = "<NFe><infNFe/></NFe>";
722        let cancel_xml = "<retEvento/>";
723        let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
724        assert!(matches!(err, FiscalError::XmlParsing(_)));
725    }
726
727    #[test]
728    fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
729        let nfe_proc = concat!(
730            r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
731            r#"<cStat>100</cStat>"#,
732            r#"</infProt></protNFe></nfeProc>"#
733        );
734        let cancel_xml = "<retEvento/>";
735        let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
736        assert!(matches!(err, FiscalError::XmlParsing(_)));
737    }
738
739    #[test]
740    fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
741        let nfe_proc = concat!(
742            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
743            r#"<NFe/>"#,
744            r#"<protNFe versao="4.00"><infProt>"#,
745            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
746            r#"<cStat>100</cStat>"#,
747            r#"</infProt></protNFe>"#,
748            r#"</nfeProc>"#
749        );
750
751        let cancel_xml = concat!(
752            r#"<retEnvEvento>"#,
753            // First: wrong chNFe
754            r#"<retEvento versao="1.00"><infEvento>"#,
755            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
756            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
757            r#"<nProt>111111111111111</nProt>"#,
758            r#"</infEvento></retEvento>"#,
759            // Second: correct match
760            r#"<retEvento versao="1.00"><infEvento>"#,
761            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
762            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
763            r#"<nProt>222222222222222</nProt>"#,
764            r#"</infEvento></retEvento>"#,
765            r#"</retEnvEvento>"#
766        );
767
768        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
769        assert!(result.contains("<nProt>222222222222222</nProt>"));
770        // Should only have one retEvento (the matching one)
771        assert_eq!(result.matches("<retEvento").count(), 1);
772    }
773}