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
6const DEFAULT_VERSION: &str = "4.00";
8
9pub 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 let digest_nfe = extract_xml_tag_value(request_xml, "DigestValue");
46 let access_key = extract_inf_nfe_id(request_xml);
47
48 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 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 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 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
105pub 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 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 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
162pub 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 let version = extract_attribute(&evento_content, "evento", "versao")
198 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
199
200 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
218pub 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
266const EVT_CANCELA: &str = "110111";
268const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
270
271const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
277
278pub fn attach_cancellation(
304 nfe_proc_xml: &str,
305 cancel_event_xml: &str,
306) -> Result<String, FiscalError> {
307 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 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 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 break;
349 }
350 }
351
352 Ok(nfe_proc_xml.to_string())
354}
355
356fn 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
375fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
381 let open_pattern = format!("<{tag_name}");
383 let start = xml.find(&open_pattern)?;
384
385 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
407fn 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 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
449fn 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 let tag_end = xml[start..].find('>')? + start;
457 let tag_header = &xml[start..tag_end];
458
459 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
467fn 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 #[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 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 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 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 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>"#, 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 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>"#, 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 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 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 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 assert_eq!(result.matches("<retEvento").count(), 1);
772 }
773}