1use crate::FiscalError;
2use crate::constants::NFE_NAMESPACE;
3use crate::status_codes::{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 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 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 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 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 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
134pub fn attach_inutilizacao(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
150 if request_xml.is_empty() {
151 return Err(FiscalError::XmlParsing(
152 "Inutilizacao request XML is empty".into(),
153 ));
154 }
155 if response_xml.is_empty() {
156 return Err(FiscalError::XmlParsing(
157 "Inutilizacao response XML is empty".into(),
158 ));
159 }
160
161 let inut_content = extract_tag(request_xml, "inutNFe").ok_or_else(|| {
162 FiscalError::XmlParsing("Could not find <inutNFe> tag in request XML".into())
163 })?;
164
165 let ret_inut_content = extract_tag(response_xml, "retInutNFe").ok_or_else(|| {
166 FiscalError::XmlParsing("Could not find <retInutNFe> tag in response XML".into())
167 })?;
168
169 let c_stat = extract_xml_tag_value(&ret_inut_content, "cStat").unwrap_or_default();
171 if c_stat != sefaz_status::VOIDED {
172 let x_motivo = extract_xml_tag_value(&ret_inut_content, "xMotivo").unwrap_or_default();
173 return Err(FiscalError::SefazRejection {
174 code: c_stat,
175 message: x_motivo,
176 });
177 }
178
179 let version = extract_attribute(&inut_content, "inutNFe", "versao")
181 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
182
183 let ret_version = extract_attribute(&ret_inut_content, "retInutNFe", "versao")
185 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
186
187 let cpf_or_cnpj_tag = if extract_xml_tag_value(&inut_content, "CNPJ").is_some() {
189 "CNPJ"
190 } else {
191 "CPF"
192 };
193
194 let field_pairs: &[(&str, &str, &str)] = &[("versao", &version, &ret_version)];
195 for &(name, req_val, ret_val) in field_pairs {
196 if req_val != ret_val {
197 return Err(FiscalError::XmlParsing(format!(
198 "Inutilização: {name} diverge entre request ({req_val}) e response ({ret_val})"
199 )));
200 }
201 }
202
203 let tag_pairs: &[&str] = &[
204 "tpAmb",
205 "cUF",
206 "ano",
207 cpf_or_cnpj_tag,
208 "mod",
209 "serie",
210 "nNFIni",
211 "nNFFin",
212 ];
213 for tag_name in tag_pairs {
214 let req_val = extract_xml_tag_value(&inut_content, tag_name).unwrap_or_default();
215 let ret_val = extract_xml_tag_value(&ret_inut_content, tag_name).unwrap_or_default();
216 if req_val != ret_val {
217 return Err(FiscalError::XmlParsing(format!(
218 "Inutilização: <{tag_name}> diverge entre request ({req_val}) e response ({ret_val})"
219 )));
220 }
221 }
222
223 Ok(join_xml(
224 &inut_content,
225 &ret_inut_content,
226 "ProcInutNFe",
227 &version,
228 ))
229}
230
231pub fn attach_event_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
250 if request_xml.is_empty() {
251 return Err(FiscalError::XmlParsing("Event request XML is empty".into()));
252 }
253 if response_xml.is_empty() {
254 return Err(FiscalError::XmlParsing(
255 "Event response XML is empty".into(),
256 ));
257 }
258
259 let evento_content = extract_tag(request_xml, "evento").ok_or_else(|| {
260 FiscalError::XmlParsing("Could not find <evento> tag in request XML".into())
261 })?;
262
263 let ret_evento_content = extract_tag(response_xml, "retEvento").ok_or_else(|| {
264 FiscalError::XmlParsing("Could not find <retEvento> tag in response XML".into())
265 })?;
266
267 let version = extract_attribute(&evento_content, "evento", "versao")
269 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
270
271 let c_stat = extract_xml_tag_value(&ret_evento_content, "cStat").unwrap_or_default();
273 let tp_evento = extract_xml_tag_value(&ret_evento_content, "tpEvento").unwrap_or_default();
274
275 let mut valid_statuses: Vec<&str> = vec!["135", "136"];
277 if tp_evento == EVT_CANCELA {
278 valid_statuses.push("155");
279 }
280
281 if !valid_statuses.contains(&c_stat.as_str()) {
282 let x_motivo = extract_xml_tag_value(&ret_evento_content, "xMotivo").unwrap_or_default();
283 return Err(FiscalError::SefazRejection {
284 code: c_stat,
285 message: x_motivo,
286 });
287 }
288
289 let req_id_lote = extract_xml_tag_value(request_xml, "idLote")
293 .ok_or_else(|| FiscalError::XmlParsing("idLote not found in request XML".into()))?;
294 let ret_id_lote = extract_xml_tag_value(response_xml, "idLote")
295 .ok_or_else(|| FiscalError::XmlParsing("idLote not found in response XML".into()))?;
296 if req_id_lote != ret_id_lote {
297 return Err(FiscalError::XmlParsing(
298 "Os números de lote dos documentos são diferentes".into(),
299 ));
300 }
301
302 Ok(join_xml(
303 &evento_content,
304 &ret_evento_content,
305 "procEventoNFe",
306 &version,
307 ))
308}
309
310pub fn attach_b2b(
326 nfe_proc_xml: &str,
327 b2b_xml: &str,
328 tag_b2b: Option<&str>,
329) -> Result<String, FiscalError> {
330 let tag_name = tag_b2b.unwrap_or("NFeB2BFin");
331
332 if !nfe_proc_xml.contains("<nfeProc") {
333 return Err(FiscalError::XmlParsing(
334 "XML does not contain <nfeProc> — is this an authorized NFe?".into(),
335 ));
336 }
337
338 let open_check = format!("<{tag_name}");
339 if !b2b_xml.contains(&open_check) {
340 return Err(FiscalError::XmlParsing(format!(
341 "B2B XML does not contain <{tag_name}> tag"
342 )));
343 }
344
345 let nfe_proc_content = extract_tag(nfe_proc_xml, "nfeProc")
346 .ok_or_else(|| FiscalError::XmlParsing("Could not extract <nfeProc> from XML".into()))?;
347
348 let nfe_proc_normalized = normalize_nfe_proc_attrs(&nfe_proc_content);
351
352 let b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
353 FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
354 })?;
355
356 let raw = format!(
357 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
358 <nfeProcB2B>{nfe_proc_normalized}{b2b_content}</nfeProcB2B>"
359 );
360
361 let cleaned = strip_newlines(&raw);
367 Ok(cleaned)
368}
369
370fn strip_newlines(s: &str) -> String {
377 let mut result = String::with_capacity(s.len());
378 let mut chars = s.chars().peekable();
379 while let Some(c) = chars.next() {
380 if c == '\n' || c == '\r' {
381 continue;
382 }
383 if c == '\\' {
384 if let Some(&'s') = chars.peek() {
385 chars.next(); continue;
387 }
388 }
389 result.push(c);
390 }
391 result
392}
393
394pub fn to_authorize(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
416 if request_xml.is_empty() {
417 return Err(FiscalError::XmlParsing(
418 "Erro ao protocolar: o XML a protocolar está vazio.".into(),
419 ));
420 }
421 if response_xml.is_empty() {
422 return Err(FiscalError::XmlParsing(
423 "Erro ao protocolar: o retorno da SEFAZ está vazio.".into(),
424 ));
425 }
426
427 if contains_xml_tag(request_xml, "NFe") {
433 attach_protocol(request_xml, response_xml)
434 } else if contains_xml_tag(request_xml, "envEvento") {
435 attach_event_protocol(request_xml, response_xml)
436 } else if contains_xml_tag(request_xml, "inutNFe") {
437 attach_inutilizacao(request_xml, response_xml)
438 } else {
439 Err(FiscalError::XmlParsing(
440 "Tipo de documento não reconhecido para protocolação".into(),
441 ))
442 }
443}
444
445fn contains_xml_tag(xml: &str, tag_name: &str) -> bool {
447 let pattern = format!("<{tag_name}");
448 for (i, _) in xml.match_indices(&pattern) {
449 let after = i + pattern.len();
450 if after >= xml.len() {
451 return true;
452 }
453 let next = xml.as_bytes()[after];
454 if next == b' '
455 || next == b'>'
456 || next == b'/'
457 || next == b'\n'
458 || next == b'\r'
459 || next == b'\t'
460 {
461 return true;
462 }
463 }
464 false
465}
466
467const EVT_CANCELA: &str = "110111";
469const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
471
472const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
478
479pub fn attach_cancellation(
505 nfe_proc_xml: &str,
506 cancel_event_xml: &str,
507) -> Result<String, FiscalError> {
508 let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
510 FiscalError::XmlParsing(
511 "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
512 )
513 })?;
514
515 let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
516 .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
517
518 let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
520
521 let mut matched_ret_evento: Option<&str> = None;
522
523 for ret_evento in &ret_eventos {
524 let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
525 Some(v) => v,
526 None => continue,
527 };
528 let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
529 Some(v) => v,
530 None => continue,
531 };
532 let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
533 Some(v) => v,
534 None => continue,
535 };
536
537 if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
538 && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
539 && ch_nfe_evento == ch_nfe
540 {
541 matched_ret_evento = Some(ret_evento.as_str());
542 break;
543 }
544 }
545
546 dom_reserialize_nfe_proc(nfe_proc_xml, matched_ret_evento)
550}
551
552fn normalize_nfe_proc_attrs(nfe_proc_xml: &str) -> String {
560 let xmlns = extract_attribute(nfe_proc_xml, "nfeProc", "xmlns");
561 let versao = extract_attribute(nfe_proc_xml, "nfeProc", "versao");
562
563 if let (Some(xmlns_val), Some(versao_val)) = (xmlns, versao) {
564 let open_pattern = "<nfeProc";
566 if let Some(start) = nfe_proc_xml.find(open_pattern) {
567 if let Some(gt_offset) = nfe_proc_xml[start..].find('>') {
568 let gt_pos = start + gt_offset;
569 let old_opening = &nfe_proc_xml[start..=gt_pos];
570 let new_opening =
571 format!("<nfeProc xmlns=\"{xmlns_val}\" versao=\"{versao_val}\">");
572 if old_opening != new_opening {
573 return nfe_proc_xml.replacen(old_opening, &new_opening, 1);
574 }
575 }
576 }
577 }
578
579 nfe_proc_xml.to_string()
580}
581
582fn dom_reserialize_nfe_proc(
597 nfe_proc_xml: &str,
598 extra_child: Option<&str>,
599) -> Result<String, FiscalError> {
600 let encoding = extract_xml_declaration_encoding(nfe_proc_xml);
602
603 let xml_decl = match &encoding {
605 Some(enc) => format!("<?xml version=\"1.0\" encoding=\"{enc}\"?>"),
606 None => "<?xml version=\"1.0\"?>".to_string(),
607 };
608
609 let nfe_proc_full = extract_tag(nfe_proc_xml, "nfeProc")
612 .ok_or_else(|| FiscalError::XmlParsing("Could not find <nfeProc> in NF-e XML".into()))?;
613
614 let versao = extract_attribute(&nfe_proc_full, "nfeProc", "versao")
616 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
617 let xmlns = extract_attribute(&nfe_proc_full, "nfeProc", "xmlns")
618 .unwrap_or_else(|| NFE_NAMESPACE.to_string());
619
620 let inner = extract_tag_inner_content(&nfe_proc_full, "nfeProc").ok_or_else(|| {
623 FiscalError::XmlParsing("Could not extract <nfeProc> inner content".into())
624 })?;
625
626 let mut result = String::with_capacity(
628 xml_decl.len() + 1 + 60 + inner.len() + extra_child.map_or(0, |c| c.len()) + 12,
629 );
630 result.push_str(&xml_decl);
631 result.push('\n');
632 result.push_str(&format!("<nfeProc xmlns=\"{xmlns}\" versao=\"{versao}\">"));
633 result.push_str(inner);
634 if let Some(child) = extra_child {
635 result.push_str(child);
636 }
637 result.push_str("</nfeProc>\n");
638
639 Ok(result)
640}
641
642fn extract_xml_declaration_encoding(xml: &str) -> Option<String> {
647 let decl_start = xml.find("<?xml ")?;
648 let decl_end = xml[decl_start..].find("?>")? + decl_start;
649 let decl = &xml[decl_start..decl_end + 2];
650
651 let enc_pat = "encoding=\"";
652 let enc_start = decl.find(enc_pat)? + enc_pat.len();
653 let enc_end = decl[enc_start..].find('"')? + enc_start;
654 Some(decl[enc_start..enc_end].to_string())
655}
656
657fn extract_tag_inner_content<'a>(xml: &'a str, tag_name: &str) -> Option<&'a str> {
660 let open_pattern = format!("<{tag_name}");
661 let start = xml.find(&open_pattern)?;
662
663 let gt_pos = xml[start..].find('>')? + start;
665
666 let close_tag = format!("</{tag_name}>");
667 let close_pos = xml.rfind(&close_tag)?;
668
669 if gt_pos + 1 > close_pos {
670 return Some("");
671 }
672
673 Some(&xml[gt_pos + 1..close_pos])
674}
675
676fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
686 format!(
687 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
688 <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
689 {first}{second}</{node_name}>"
690 )
691}
692
693fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
699 let open_pattern = format!("<{tag_name}");
701 let start = xml.find(&open_pattern)?;
702
703 let after_open = start + open_pattern.len();
706 if after_open < xml.len() {
707 let next_char = xml.as_bytes()[after_open];
708 if next_char != b' '
709 && next_char != b'>'
710 && next_char != b'/'
711 && next_char != b'\n'
712 && next_char != b'\r'
713 && next_char != b'\t'
714 {
715 return None;
716 }
717 }
718
719 let close_tag = format!("</{tag_name}>");
720 let close_index = xml.rfind(&close_tag)?;
721
722 Some(xml[start..close_index + close_tag.len()].to_string())
723}
724
725fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
728 let mut results = Vec::new();
729 let open_pattern = format!("<{tag_name}");
730 let close_tag = format!("</{tag_name}>");
731 let mut search_from = 0;
732
733 while search_from < xml.len() {
734 let start = match xml[search_from..].find(&open_pattern) {
735 Some(pos) => search_from + pos,
736 None => break,
737 };
738
739 let after_open = start + open_pattern.len();
741 if after_open < xml.len() {
742 let next_char = xml.as_bytes()[after_open];
743 if next_char != b' '
744 && next_char != b'>'
745 && next_char != b'/'
746 && next_char != b'\n'
747 && next_char != b'\r'
748 && next_char != b'\t'
749 {
750 search_from = after_open;
751 continue;
752 }
753 }
754
755 let end = match xml[start..].find(&close_tag) {
756 Some(pos) => start + pos + close_tag.len(),
757 None => break,
758 };
759
760 results.push(xml[start..end].to_string());
761 search_from = end;
762 }
763
764 results
765}
766
767fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
770 let open = format!("<{tag_name}");
771 let start = xml.find(&open)?;
772
773 let tag_end = xml[start..].find('>')? + start;
775 let tag_header = &xml[start..tag_end];
776
777 let attr_pattern = format!("{attr_name}=\"");
779 let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
780 let attr_end = tag_header[attr_start..].find('"')? + attr_start;
781
782 Some(tag_header[attr_start..attr_end].to_string())
783}
784
785fn extract_inf_nfe_id(xml: &str) -> Option<String> {
788 let attr_val = extract_attribute(xml, "infNFe", "Id")?;
789 Some(
790 attr_val
791 .strip_prefix("NFe")
792 .unwrap_or(&attr_val)
793 .to_string(),
794 )
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 #[test]
802 fn extract_tag_finds_outermost_match() {
803 let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
804 let result = extract_tag(xml, "NFe").unwrap();
805 assert!(result.starts_with("<NFe"));
806 assert!(result.ends_with("</NFe>"));
807 assert!(result.contains("<inner/>"));
808 }
809
810 #[test]
811 fn extract_tag_returns_none_for_missing_tag() {
812 let xml = "<root><other/></root>";
813 assert!(extract_tag(xml, "NFe").is_none());
814 }
815
816 #[test]
817 fn extract_tag_does_not_match_prefix() {
818 let xml = "<root><NFeExtra>data</NFeExtra></root>";
819 assert!(extract_tag(xml, "NFe").is_none());
820 }
821
822 #[test]
823 fn extract_attribute_works() {
824 let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
825 assert_eq!(
826 extract_attribute(xml, "infNFe", "versao"),
827 Some("4.00".to_string())
828 );
829 assert_eq!(
830 extract_attribute(xml, "infNFe", "Id"),
831 Some("NFe12345".to_string())
832 );
833 }
834
835 #[test]
836 fn extract_all_tags_finds_multiple() {
837 let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
838 let items = extract_all_tags(xml, "item");
839 assert_eq!(items.len(), 3);
840 assert!(items[0].contains("1"));
841 assert!(items[2].contains("3"));
842 }
843
844 #[test]
845 fn join_xml_produces_correct_wrapper() {
846 let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
847 assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
848 assert!(result.contains("<wrapper versao=\"4.00\""));
849 assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
850 assert!(result.ends_with("</wrapper>"));
851 }
852
853 #[test]
854 fn extract_inf_nfe_id_strips_prefix() {
855 let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
856 let key = extract_inf_nfe_id(xml).unwrap();
857 assert_eq!(key, "35260112345678000199650010000000011123456780");
858 }
859
860 #[test]
863 fn attach_cancellation_appends_matching_ret_evento() {
864 let nfe_proc = concat!(
865 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
866 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
867 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
868 r#"<ide/></infNFe></NFe>"#,
869 r#"<protNFe versao="4.00"><infProt>"#,
870 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
871 r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
872 r#"</infProt></protNFe>"#,
873 r#"</nfeProc>"#
874 );
875
876 let cancel_xml = concat!(
877 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
878 r#"<cStat>135</cStat>"#,
879 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
880 r#"<tpEvento>110111</tpEvento>"#,
881 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
882 r#"<nProt>135220000009999</nProt>"#,
883 r#"</infEvento></retEvento></retEnvEvento>"#
884 );
885
886 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
887
888 assert!(
890 result.contains("<retEvento"),
891 "Result should contain <retEvento>"
892 );
893 assert!(
894 result.contains("<tpEvento>110111</tpEvento>"),
895 "Result should contain cancellation event type"
896 );
897 let ret_pos = result.find("<retEvento").unwrap();
899 let close_pos = result.rfind("</nfeProc>").unwrap();
900 assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
901 assert!(result.contains("<protNFe"));
903 assert!(result.contains("<NFe>"));
904 }
905
906 #[test]
907 fn attach_cancellation_ignores_non_matching_ch_nfe() {
908 let nfe_proc = concat!(
909 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
910 r#"<NFe/>"#,
911 r#"<protNFe versao="4.00"><infProt>"#,
912 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
913 r#"<cStat>100</cStat>"#,
914 r#"</infProt></protNFe>"#,
915 r#"</nfeProc>"#
916 );
917
918 let cancel_xml = concat!(
919 r#"<retEvento versao="1.00"><infEvento>"#,
920 r#"<cStat>135</cStat>"#,
921 r#"<tpEvento>110111</tpEvento>"#,
922 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
923 r#"<nProt>135220000009999</nProt>"#,
924 r#"</infEvento></retEvento>"#
925 );
926
927 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
928 assert!(
931 !result.contains("<retEvento"),
932 "Should not contain retEvento"
933 );
934 assert!(
935 result.starts_with("<?xml version=\"1.0\"?>\n"),
936 "Should have XML declaration (no encoding since input had none)"
937 );
938 assert!(
939 result
940 .contains(r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#),
941 "Should reorder xmlns before versao"
942 );
943 }
944
945 #[test]
946 fn attach_cancellation_ignores_wrong_tp_evento() {
947 let nfe_proc = concat!(
948 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
949 r#"<NFe/>"#,
950 r#"<protNFe versao="4.00"><infProt>"#,
951 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
952 r#"<cStat>100</cStat>"#,
953 r#"</infProt></protNFe>"#,
954 r#"</nfeProc>"#
955 );
956
957 let cancel_xml = concat!(
958 r#"<retEvento versao="1.00"><infEvento>"#,
959 r#"<cStat>135</cStat>"#,
960 r#"<tpEvento>110110</tpEvento>"#, r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
962 r#"<nProt>135220000009999</nProt>"#,
963 r#"</infEvento></retEvento>"#
964 );
965
966 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
967 assert!(
969 !result.contains("<retEvento"),
970 "Should not contain retEvento"
971 );
972 assert!(
973 result.starts_with("<?xml version=\"1.0\"?>\n"),
974 "Should have XML declaration"
975 );
976 }
977
978 #[test]
979 fn attach_cancellation_ignores_rejected_status() {
980 let nfe_proc = concat!(
981 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
982 r#"<NFe/>"#,
983 r#"<protNFe versao="4.00"><infProt>"#,
984 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
985 r#"<cStat>100</cStat>"#,
986 r#"</infProt></protNFe>"#,
987 r#"</nfeProc>"#
988 );
989
990 let cancel_xml = concat!(
991 r#"<retEvento versao="1.00"><infEvento>"#,
992 r#"<cStat>573</cStat>"#, r#"<tpEvento>110111</tpEvento>"#,
994 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
995 r#"<nProt>135220000009999</nProt>"#,
996 r#"</infEvento></retEvento>"#
997 );
998
999 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1000 assert!(
1002 !result.contains("<retEvento"),
1003 "Should not contain retEvento"
1004 );
1005 assert!(
1006 result.starts_with("<?xml version=\"1.0\"?>\n"),
1007 "Should have XML declaration"
1008 );
1009 }
1010
1011 #[test]
1012 fn attach_cancellation_accepts_status_155() {
1013 let nfe_proc = concat!(
1014 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1015 r#"<NFe/>"#,
1016 r#"<protNFe versao="4.00"><infProt>"#,
1017 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1018 r#"<cStat>100</cStat>"#,
1019 r#"</infProt></protNFe>"#,
1020 r#"</nfeProc>"#
1021 );
1022
1023 let cancel_xml = concat!(
1024 r#"<retEvento versao="1.00"><infEvento>"#,
1025 r#"<cStat>155</cStat>"#,
1026 r#"<tpEvento>110111</tpEvento>"#,
1027 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1028 r#"<nProt>135220000009999</nProt>"#,
1029 r#"</infEvento></retEvento>"#
1030 );
1031
1032 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1033 assert!(result.contains("<retEvento"));
1034 }
1035
1036 #[test]
1037 fn attach_cancellation_accepts_substituicao_110112() {
1038 let nfe_proc = concat!(
1039 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1040 r#"<NFe/>"#,
1041 r#"<protNFe versao="4.00"><infProt>"#,
1042 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1043 r#"<cStat>100</cStat>"#,
1044 r#"</infProt></protNFe>"#,
1045 r#"</nfeProc>"#
1046 );
1047
1048 let cancel_xml = concat!(
1049 r#"<retEvento versao="1.00"><infEvento>"#,
1050 r#"<cStat>135</cStat>"#,
1051 r#"<tpEvento>110112</tpEvento>"#,
1052 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1053 r#"<nProt>135220000009999</nProt>"#,
1054 r#"</infEvento></retEvento>"#
1055 );
1056
1057 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1058 assert!(
1059 result.contains("<tpEvento>110112</tpEvento>"),
1060 "Should accept cancellation by substitution"
1061 );
1062 }
1063
1064 #[test]
1065 fn attach_cancellation_rejects_missing_prot_nfe() {
1066 let nfe_xml = "<NFe><infNFe/></NFe>";
1067 let cancel_xml = "<retEvento/>";
1068 let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
1069 assert!(matches!(err, FiscalError::XmlParsing(_)));
1070 }
1071
1072 #[test]
1073 fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
1074 let nfe_proc = concat!(
1075 r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
1076 r#"<cStat>100</cStat>"#,
1077 r#"</infProt></protNFe></nfeProc>"#
1078 );
1079 let cancel_xml = "<retEvento/>";
1080 let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
1081 assert!(matches!(err, FiscalError::XmlParsing(_)));
1082 }
1083
1084 #[test]
1092 fn attach_cancellation_parity_with_php() {
1093 let nfe_proc = concat!(
1095 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1096 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1097 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1098 r#"<ide/></infNFe></NFe>"#,
1099 r#"<protNFe versao="4.00"><infProt>"#,
1100 r#"<digVal>abc</digVal>"#,
1101 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1102 r#"<cStat>100</cStat>"#,
1103 r#"<xMotivo>Autorizado</xMotivo>"#,
1104 r#"<nProt>135220000009921</nProt>"#,
1105 r#"</infProt></protNFe>"#,
1106 r#"</nfeProc>"#
1107 );
1108
1109 let cancel_xml = concat!(
1110 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1111 r#"<cStat>135</cStat>"#,
1112 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1113 r#"<tpEvento>110111</tpEvento>"#,
1114 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1115 r#"<nProt>135220000009999</nProt>"#,
1116 r#"</infEvento></retEvento></retEnvEvento>"#
1117 );
1118
1119 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1120
1121 let expected = concat!(
1127 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1128 r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
1129 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1130 r#"<ide/></infNFe></NFe>"#,
1131 r#"<protNFe versao="4.00"><infProt>"#,
1132 r#"<digVal>abc</digVal>"#,
1133 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1134 r#"<cStat>100</cStat>"#,
1135 r#"<xMotivo>Autorizado</xMotivo>"#,
1136 r#"<nProt>135220000009921</nProt>"#,
1137 r#"</infProt></protNFe>"#,
1138 r#"<retEvento versao="1.00"><infEvento>"#,
1139 r#"<cStat>135</cStat>"#,
1140 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1141 r#"<tpEvento>110111</tpEvento>"#,
1142 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1143 r#"<nProt>135220000009999</nProt>"#,
1144 r#"</infEvento></retEvento>"#,
1145 "</nfeProc>\n"
1146 );
1147
1148 assert_eq!(result, expected);
1149 }
1150
1151 #[test]
1153 fn attach_cancellation_no_match_still_reserializes_like_php() {
1154 let nfe_proc = concat!(
1155 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1156 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1157 r#"<NFe/>"#,
1158 r#"<protNFe versao="4.00"><infProt>"#,
1159 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1160 r#"<cStat>100</cStat>"#,
1161 r#"</infProt></protNFe>"#,
1162 r#"</nfeProc>"#
1163 );
1164
1165 let cancel_xml = concat!(
1166 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1167 r#"<cStat>135</cStat>"#,
1168 r#"<tpEvento>110111</tpEvento>"#,
1169 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1170 r#"<nProt>135220000009999</nProt>"#,
1171 r#"</infEvento></retEvento></retEnvEvento>"#
1172 );
1173
1174 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1175
1176 let expected = concat!(
1178 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1179 r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
1180 r#"<NFe/>"#,
1181 r#"<protNFe versao="4.00"><infProt>"#,
1182 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1183 r#"<cStat>100</cStat>"#,
1184 r#"</infProt></protNFe>"#,
1185 "</nfeProc>\n"
1186 );
1187
1188 assert_eq!(result, expected);
1189 }
1190
1191 #[test]
1193 fn extract_xml_declaration_encoding_works() {
1194 assert_eq!(
1195 extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#),
1196 Some("UTF-8".to_string())
1197 );
1198 assert_eq!(
1199 extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="utf-8"?><root/>"#),
1200 Some("utf-8".to_string())
1201 );
1202 assert_eq!(
1203 extract_xml_declaration_encoding(r#"<?xml version="1.0"?><root/>"#),
1204 None
1205 );
1206 assert_eq!(extract_xml_declaration_encoding(r#"<root/>"#), None);
1207 }
1208
1209 #[test]
1210 fn extract_tag_inner_content_works() {
1211 assert_eq!(
1212 extract_tag_inner_content(r#"<root attr="val">inner content</root>"#, "root"),
1213 Some("inner content")
1214 );
1215 assert_eq!(
1216 extract_tag_inner_content(r#"<root></root>"#, "root"),
1217 Some("")
1218 );
1219 }
1220
1221 #[test]
1224 fn attach_protocol_empty_request_xml() {
1225 let err = attach_protocol("", "<protNFe/>").unwrap_err();
1226 assert!(matches!(err, FiscalError::XmlParsing(_)));
1227 }
1228
1229 #[test]
1230 fn attach_protocol_empty_response_xml() {
1231 let err = attach_protocol("<NFe/>", "").unwrap_err();
1232 assert!(matches!(err, FiscalError::XmlParsing(_)));
1233 }
1234
1235 #[test]
1236 fn attach_protocol_matching_digest_and_key() {
1237 let request = concat!(
1238 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1239 r#"<ide/></infNFe>"#,
1240 r#"<Signature><SignedInfo/><SignatureValue/>"#,
1241 r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1242 r#"</NFe>"#
1243 );
1244 let response = concat!(
1245 r#"<protNFe versao="4.00"><infProt>"#,
1246 r#"<digVal>abc123</digVal>"#,
1247 r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1248 r#"<cStat>100</cStat>"#,
1249 r#"<xMotivo>Autorizado</xMotivo>"#,
1250 r#"</infProt></protNFe>"#
1251 );
1252 let result = attach_protocol(request, response).unwrap();
1253 assert!(result.contains("<nfeProc"));
1254 assert!(result.contains("</nfeProc>"));
1255 assert!(result.contains("<NFe>"));
1256 assert!(result.contains("<protNFe"));
1257 }
1258
1259 #[test]
1260 fn attach_protocol_rejected_status_in_exact_match() {
1261 let request = concat!(
1262 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1263 r#"<ide/></infNFe>"#,
1264 r#"<Signature><SignedInfo/><SignatureValue/>"#,
1265 r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1266 r#"</NFe>"#
1267 );
1268 let response = concat!(
1269 r#"<protNFe versao="4.00"><infProt>"#,
1270 r#"<digVal>abc123</digVal>"#,
1271 r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1272 r#"<cStat>999</cStat>"#,
1273 r#"<xMotivo>Rejeitada</xMotivo>"#,
1274 r#"</infProt></protNFe>"#
1275 );
1276 let err = attach_protocol(request, response).unwrap_err();
1277 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1278 }
1279
1280 #[test]
1281 fn attach_protocol_fallback_rejected_status() {
1282 let request = concat!(
1284 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1285 r#"<ide/></infNFe></NFe>"#
1286 );
1287 let response = concat!(
1288 r#"<protNFe versao="4.00"><infProt>"#,
1289 r#"<cStat>999</cStat>"#,
1290 r#"<xMotivo>Rejeitada</xMotivo>"#,
1291 r#"</infProt></protNFe>"#
1292 );
1293 let err = attach_protocol(request, response).unwrap_err();
1294 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1295 }
1296
1297 #[test]
1300 fn attach_inutilizacao_empty_request() {
1301 let err = attach_inutilizacao("", "<retInutNFe/>").unwrap_err();
1302 assert!(matches!(err, FiscalError::XmlParsing(_)));
1303 }
1304
1305 #[test]
1306 fn attach_inutilizacao_empty_response() {
1307 let err = attach_inutilizacao("<inutNFe/>", "").unwrap_err();
1308 assert!(matches!(err, FiscalError::XmlParsing(_)));
1309 }
1310
1311 #[test]
1312 fn attach_inutilizacao_missing_inut_tag() {
1313 let err = attach_inutilizacao("<other/>", "<retInutNFe><cStat>102</cStat></retInutNFe>")
1314 .unwrap_err();
1315 assert!(matches!(err, FiscalError::XmlParsing(_)));
1316 }
1317
1318 #[test]
1319 fn attach_inutilizacao_missing_ret_tag() {
1320 let err = attach_inutilizacao(r#"<inutNFe versao="4.00"><data/></inutNFe>"#, "<other/>")
1321 .unwrap_err();
1322 assert!(matches!(err, FiscalError::XmlParsing(_)));
1323 }
1324
1325 #[test]
1326 fn attach_inutilizacao_rejected_status() {
1327 let err = attach_inutilizacao(
1328 r#"<inutNFe versao="4.00"><data/></inutNFe>"#,
1329 r#"<retInutNFe><cStat>999</cStat><xMotivo>Erro</xMotivo></retInutNFe>"#,
1330 )
1331 .unwrap_err();
1332 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1333 }
1334
1335 #[test]
1336 fn attach_inutilizacao_success() {
1337 let result = attach_inutilizacao(
1338 r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#,
1339 r#"<retInutNFe><cStat>102</cStat><xMotivo>Inutilizacao de numero homologado</xMotivo></retInutNFe>"#,
1340 )
1341 .unwrap();
1342 assert!(result.contains("<ProcInutNFe"));
1343 assert!(result.contains("<inutNFe"));
1344 assert!(result.contains("<retInutNFe>"));
1345 }
1346
1347 #[test]
1350 fn attach_event_protocol_empty_request() {
1351 let err = attach_event_protocol("", "<retEvento/>").unwrap_err();
1352 assert!(matches!(err, FiscalError::XmlParsing(_)));
1353 }
1354
1355 #[test]
1356 fn attach_event_protocol_empty_response() {
1357 let err = attach_event_protocol("<evento/>", "").unwrap_err();
1358 assert!(matches!(err, FiscalError::XmlParsing(_)));
1359 }
1360
1361 #[test]
1362 fn attach_event_protocol_missing_evento() {
1363 let err = attach_event_protocol(
1364 "<other/>",
1365 "<retEvento><infEvento><cStat>135</cStat></infEvento></retEvento>",
1366 )
1367 .unwrap_err();
1368 assert!(matches!(err, FiscalError::XmlParsing(_)));
1369 }
1370
1371 #[test]
1372 fn attach_event_protocol_missing_ret_evento() {
1373 let err =
1374 attach_event_protocol(r#"<evento versao="1.00"><infEvento/></evento>"#, "<other/>")
1375 .unwrap_err();
1376 assert!(matches!(err, FiscalError::XmlParsing(_)));
1377 }
1378
1379 #[test]
1380 fn attach_event_protocol_rejected_status() {
1381 let err = attach_event_protocol(
1382 r#"<evento versao="1.00"><infEvento/></evento>"#,
1383 r#"<retEvento><infEvento><cStat>999</cStat><xMotivo>Rejeitado</xMotivo></infEvento></retEvento>"#,
1384 )
1385 .unwrap_err();
1386 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1387 }
1388
1389 #[test]
1390 fn attach_event_protocol_success() {
1391 let request = concat!(
1392 r#"<envEvento><idLote>100</idLote>"#,
1393 r#"<evento versao="1.00"><infEvento Id="ID1234"/></evento>"#,
1394 r#"</envEvento>"#
1395 );
1396 let response = concat!(
1397 r#"<retEnvEvento><idLote>100</idLote>"#,
1398 r#"<retEvento><infEvento><cStat>135</cStat>"#,
1399 r#"<xMotivo>Evento registrado</xMotivo>"#,
1400 r#"</infEvento></retEvento></retEnvEvento>"#
1401 );
1402 let result = attach_event_protocol(request, response).unwrap();
1403 assert!(result.contains("<procEventoNFe"));
1404 assert!(result.contains("<evento"));
1405 assert!(result.contains("<retEvento>"));
1406 }
1407
1408 #[test]
1411 fn attach_b2b_no_nfe_proc() {
1412 let err = attach_b2b("<NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1413 assert!(matches!(err, FiscalError::XmlParsing(_)));
1414 }
1415
1416 #[test]
1417 fn attach_b2b_no_b2b_tag() {
1418 let err = attach_b2b("<nfeProc><NFe/></nfeProc>", "<other>data</other>", None).unwrap_err();
1419 assert!(matches!(err, FiscalError::XmlParsing(_)));
1420 }
1421
1422 #[test]
1423 fn attach_b2b_extract_failure() {
1424 let err = attach_b2b("<nfeProc><NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1426 assert!(matches!(err, FiscalError::XmlParsing(_)));
1427 }
1428
1429 #[test]
1430 fn attach_b2b_success() {
1431 let result = attach_b2b(
1432 "<nfeProc><NFe/><protNFe/></nfeProc>",
1433 "<NFeB2BFin><tag>data</tag></NFeB2BFin>",
1434 None,
1435 )
1436 .unwrap();
1437 assert!(result.contains("<nfeProcB2B>"));
1438 assert!(result.contains("<nfeProc>"));
1439 assert!(result.contains("<NFeB2BFin>"));
1440 }
1441
1442 #[test]
1443 fn attach_b2b_custom_tag() {
1444 let result = attach_b2b(
1445 "<nfeProc><NFe/><protNFe/></nfeProc>",
1446 "<CustomB2B><tag>data</tag></CustomB2B>",
1447 Some("CustomB2B"),
1448 )
1449 .unwrap();
1450 assert!(result.contains("<CustomB2B>"));
1451 }
1452
1453 #[test]
1456 fn extract_all_tags_skips_prefix_match() {
1457 let xml = "<root><protNFeExtra>bad</protNFeExtra><protNFe>good</protNFe></root>";
1459 let results = extract_all_tags(xml, "protNFe");
1460 assert_eq!(results.len(), 1);
1461 assert!(results[0].contains("good"));
1462 }
1463
1464 #[test]
1465 fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
1466 let nfe_proc = concat!(
1467 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1468 r#"<NFe/>"#,
1469 r#"<protNFe versao="4.00"><infProt>"#,
1470 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1471 r#"<cStat>100</cStat>"#,
1472 r#"</infProt></protNFe>"#,
1473 r#"</nfeProc>"#
1474 );
1475
1476 let cancel_xml = concat!(
1477 r#"<retEnvEvento>"#,
1478 r#"<retEvento versao="1.00"><infEvento>"#,
1480 r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1481 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1482 r#"<nProt>111111111111111</nProt>"#,
1483 r#"</infEvento></retEvento>"#,
1484 r#"<retEvento versao="1.00"><infEvento>"#,
1486 r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1487 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1488 r#"<nProt>222222222222222</nProt>"#,
1489 r#"</infEvento></retEvento>"#,
1490 r#"</retEnvEvento>"#
1491 );
1492
1493 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1494 assert!(result.contains("<nProt>222222222222222</nProt>"));
1495 assert_eq!(result.matches("<retEvento").count(), 1);
1497 }
1498
1499 #[test]
1502 fn to_authorize_empty_request_returns_error() {
1503 let err = to_authorize("", "<retEnviNFe/>").unwrap_err();
1504 assert!(matches!(err, FiscalError::XmlParsing(_)));
1505 }
1506
1507 #[test]
1508 fn to_authorize_empty_response_returns_error() {
1509 let err = to_authorize("<NFe/>", "").unwrap_err();
1510 assert!(matches!(err, FiscalError::XmlParsing(_)));
1511 }
1512
1513 #[test]
1514 fn to_authorize_unrecognized_document_returns_error() {
1515 let err = to_authorize("<other>data</other>", "<response/>").unwrap_err();
1516 let msg = format!("{err}");
1517 assert!(
1518 msg.contains("não reconhecido"),
1519 "should mention unrecognized type: {msg}"
1520 );
1521 }
1522
1523 #[test]
1524 fn contains_xml_tag_basic() {
1525 assert!(contains_xml_tag("<NFe versao=\"4.00\">", "NFe"));
1526 assert!(contains_xml_tag("<NFe>", "NFe"));
1527 assert!(contains_xml_tag("<NFe/>", "NFe"));
1528 assert!(!contains_xml_tag("<NFeExtra>", "NFe"));
1529 assert!(contains_xml_tag("<envEvento versao=\"1.00\">", "envEvento"));
1530 assert!(contains_xml_tag("<inutNFe versao=\"4.00\">", "inutNFe"));
1531 }
1532
1533 #[test]
1536 fn attach_b2b_strips_newlines() {
1537 let nfe_proc = "<nfeProc versao=\"4.00\">\n<NFe/>\n<protNFe/>\n</nfeProc>";
1538 let b2b = "<NFeB2BFin>\n<data>test</data>\n</NFeB2BFin>";
1539 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1540 assert!(!result.contains('\n'), "Result should not contain newlines");
1541 assert!(
1542 !result.contains('\r'),
1543 "Result should not contain carriage returns"
1544 );
1545 assert!(result.contains("<nfeProcB2B>"));
1546 assert!(result.contains("<NFeB2BFin>"));
1547 }
1548
1549 #[test]
1550 fn attach_b2b_strips_carriage_returns() {
1551 let nfe_proc = "<nfeProc versao=\"4.00\">\r\n<NFe/>\r\n</nfeProc>";
1552 let b2b = "<NFeB2BFin><data>test</data></NFeB2BFin>";
1553 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1554 assert!(!result.contains('\r'));
1555 assert!(!result.contains('\n'));
1556 }
1557
1558 #[test]
1561 fn attach_protocol_fallback_prot_invalid_status() {
1562 let request = concat!(
1564 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1565 r#"<DigestValue>abc123</DigestValue>"#,
1566 r#"</infNFe></NFe>"#
1567 );
1568 let response = concat!(
1571 r#"<protNFe versao="4.00"><infProt>"#,
1572 r#"<cStat>999</cStat>"#,
1573 r#"<xMotivo>Rejeitado</xMotivo>"#,
1574 r#"</infProt></protNFe>"#
1575 );
1576 let err = attach_protocol(request, response).unwrap_err();
1577 match err {
1578 FiscalError::SefazRejection { code, .. } => assert_eq!(code, "999"),
1579 other => panic!("Expected SefazRejection, got {:?}", other),
1580 }
1581 }
1582
1583 #[test]
1586 fn attach_inutilizacao_version_mismatch() {
1587 let request = concat!(
1588 r#"<inutNFe versao="4.00"><infInut>"#,
1589 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1590 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1591 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1592 r#"</infInut></inutNFe>"#
1593 );
1594 let response = concat!(
1595 r#"<retInutNFe versao="3.10"><infInut>"#,
1596 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1597 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1598 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1599 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1600 r#"</infInut></retInutNFe>"#
1601 );
1602 let err = attach_inutilizacao(request, response).unwrap_err();
1603 match err {
1604 FiscalError::XmlParsing(msg) => {
1605 assert!(
1606 msg.contains("versao"),
1607 "Expected version mismatch error, got: {msg}"
1608 );
1609 }
1610 other => panic!("Expected XmlParsing, got {:?}", other),
1611 }
1612 }
1613
1614 #[test]
1617 fn attach_inutilizacao_tag_value_mismatch() {
1618 let request = concat!(
1619 r#"<inutNFe versao="4.00"><infInut>"#,
1620 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1621 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1622 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1623 r#"</infInut></inutNFe>"#
1624 );
1625 let response = concat!(
1626 r#"<retInutNFe versao="4.00"><infInut>"#,
1627 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1628 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1629 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>2</serie>"#,
1630 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1631 r#"</infInut></retInutNFe>"#
1632 );
1633 let err = attach_inutilizacao(request, response).unwrap_err();
1634 match err {
1635 FiscalError::XmlParsing(msg) => {
1636 assert!(
1637 msg.contains("serie"),
1638 "Expected serie mismatch error, got: {msg}"
1639 );
1640 }
1641 other => panic!("Expected XmlParsing, got {:?}", other),
1642 }
1643 }
1644
1645 #[test]
1648 fn attach_event_protocol_id_lote_mismatch() {
1649 let request = concat!(
1650 r#"<envEvento><idLote>100</idLote>"#,
1651 r#"<evento versao="1.00"><infEvento>"#,
1652 r#"<tpEvento>110110</tpEvento>"#,
1653 r#"</infEvento></evento></envEvento>"#
1654 );
1655 let response = concat!(
1656 r#"<retEnvEvento><idLote>999</idLote>"#,
1657 r#"<retEvento versao="1.00"><infEvento>"#,
1658 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1659 r#"<tpEvento>110110</tpEvento>"#,
1660 r#"</infEvento></retEvento></retEnvEvento>"#
1661 );
1662 let err = attach_event_protocol(request, response).unwrap_err();
1663 match err {
1664 FiscalError::XmlParsing(msg) => {
1665 assert!(
1666 msg.contains("lote"),
1667 "Expected lote mismatch error, got: {msg}"
1668 );
1669 }
1670 other => panic!("Expected XmlParsing, got {:?}", other),
1671 }
1672 }
1673
1674 #[test]
1677 fn attach_event_protocol_both_invalid_reports_cstat_first() {
1678 let request = concat!(
1682 r#"<envEvento><idLote>100</idLote>"#,
1683 r#"<evento versao="1.00"><infEvento>"#,
1684 r#"<tpEvento>110110</tpEvento>"#,
1685 r#"</infEvento></evento></envEvento>"#
1686 );
1687 let response = concat!(
1688 r#"<retEnvEvento><idLote>999</idLote>"#,
1689 r#"<retEvento versao="1.00"><infEvento>"#,
1690 r#"<cStat>573</cStat><xMotivo>Duplicidade de evento</xMotivo>"#,
1691 r#"<tpEvento>110110</tpEvento>"#,
1692 r#"</infEvento></retEvento></retEnvEvento>"#
1693 );
1694 let err = attach_event_protocol(request, response).unwrap_err();
1695 match err {
1696 FiscalError::SefazRejection { code, message } => {
1697 assert_eq!(code, "573");
1698 assert_eq!(message, "Duplicidade de evento");
1699 }
1700 other => panic!("Expected SefazRejection (cStat first), got {:?}", other),
1701 }
1702 }
1703
1704 #[test]
1707 fn attach_event_protocol_missing_id_lote_in_request() {
1708 let request = concat!(
1709 r#"<envEvento>"#,
1710 r#"<evento versao="1.00"><infEvento>"#,
1711 r#"<tpEvento>110110</tpEvento>"#,
1712 r#"</infEvento></evento></envEvento>"#
1713 );
1714 let response = concat!(
1715 r#"<retEnvEvento><idLote>100</idLote>"#,
1716 r#"<retEvento versao="1.00"><infEvento>"#,
1717 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1718 r#"<tpEvento>110110</tpEvento>"#,
1719 r#"</infEvento></retEvento></retEnvEvento>"#
1720 );
1721 let err = attach_event_protocol(request, response).unwrap_err();
1722 match err {
1723 FiscalError::XmlParsing(msg) => {
1724 assert_eq!(msg, "idLote not found in request XML");
1725 }
1726 other => panic!("Expected XmlParsing, got {:?}", other),
1727 }
1728 }
1729
1730 #[test]
1731 fn attach_event_protocol_missing_id_lote_in_response() {
1732 let request = concat!(
1733 r#"<envEvento><idLote>100</idLote>"#,
1734 r#"<evento versao="1.00"><infEvento>"#,
1735 r#"<tpEvento>110110</tpEvento>"#,
1736 r#"</infEvento></evento></envEvento>"#
1737 );
1738 let response = concat!(
1739 r#"<retEnvEvento>"#,
1740 r#"<retEvento versao="1.00"><infEvento>"#,
1741 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1742 r#"<tpEvento>110110</tpEvento>"#,
1743 r#"</infEvento></retEvento></retEnvEvento>"#
1744 );
1745 let err = attach_event_protocol(request, response).unwrap_err();
1746 match err {
1747 FiscalError::XmlParsing(msg) => {
1748 assert_eq!(msg, "idLote not found in response XML");
1749 }
1750 other => panic!("Expected XmlParsing, got {:?}", other),
1751 }
1752 }
1753
1754 #[test]
1755 fn attach_event_protocol_missing_id_lote_in_both() {
1756 let request = concat!(
1757 r#"<envEvento>"#,
1758 r#"<evento versao="1.00"><infEvento>"#,
1759 r#"<tpEvento>110110</tpEvento>"#,
1760 r#"</infEvento></evento></envEvento>"#
1761 );
1762 let response = concat!(
1763 r#"<retEnvEvento>"#,
1764 r#"<retEvento versao="1.00"><infEvento>"#,
1765 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1766 r#"<tpEvento>110110</tpEvento>"#,
1767 r#"</infEvento></retEvento></retEnvEvento>"#
1768 );
1769 let err = attach_event_protocol(request, response).unwrap_err();
1770 match err {
1771 FiscalError::XmlParsing(msg) => {
1772 assert_eq!(msg, "idLote not found in request XML");
1773 }
1774 other => panic!("Expected XmlParsing, got {:?}", other),
1775 }
1776 }
1777
1778 #[test]
1781 fn attach_b2b_extract_tag_coverage() {
1782 let nfe_proc = concat!(
1783 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1784 r#"<NFe><infNFe/></NFe><protNFe><infProt/></protNFe>"#,
1785 r#"</nfeProc>"#
1786 );
1787 let b2b = r#"<NFeB2BFin versao="1.00"><dados>value</dados></NFeB2BFin>"#;
1788 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1789 assert!(result.contains("<nfeProcB2B>"));
1790 assert!(result.contains("<dados>value</dados>"));
1791 }
1792
1793 #[test]
1796 fn to_authorize_dispatches_nfe() {
1797 let request = concat!(
1798 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1799 r#"<DigestValue>abc</DigestValue>"#,
1800 r#"</infNFe></NFe>"#
1801 );
1802 let response = concat!(
1803 r#"<protNFe versao="4.00"><infProt>"#,
1804 r#"<cStat>100</cStat><xMotivo>OK</xMotivo>"#,
1805 r#"<digVal>abc</digVal>"#,
1806 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1807 r#"</infProt></protNFe>"#
1808 );
1809 let result = to_authorize(request, response).unwrap();
1810 assert!(result.contains("<nfeProc"));
1811 }
1812
1813 #[test]
1816 fn to_authorize_dispatches_env_evento() {
1817 let request = concat!(
1818 r#"<envEvento><idLote>1</idLote>"#,
1819 r#"<evento versao="1.00"><infEvento>"#,
1820 r#"<tpEvento>110110</tpEvento>"#,
1821 r#"</infEvento></evento></envEvento>"#
1822 );
1823 let response = concat!(
1824 r#"<retEnvEvento><idLote>1</idLote>"#,
1825 r#"<retEvento versao="1.00"><infEvento>"#,
1826 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1827 r#"<tpEvento>110110</tpEvento>"#,
1828 r#"</infEvento></retEvento></retEnvEvento>"#
1829 );
1830 let result = to_authorize(request, response).unwrap();
1831 assert!(result.contains("<procEventoNFe"));
1832 }
1833
1834 #[test]
1837 fn to_authorize_dispatches_inut_nfe() {
1838 let request = concat!(
1839 r#"<inutNFe versao="4.00"><infInut>"#,
1840 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1841 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1842 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1843 r#"</infInut></inutNFe>"#
1844 );
1845 let response = concat!(
1846 r#"<retInutNFe versao="4.00"><infInut>"#,
1847 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1848 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1849 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1850 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1851 r#"</infInut></retInutNFe>"#
1852 );
1853 let result = to_authorize(request, response).unwrap();
1854 assert!(result.contains("<ProcInutNFe"));
1855 }
1856
1857 #[test]
1860 fn contains_xml_tag_at_end_of_string() {
1861 assert!(contains_xml_tag("<NFe", "NFe"));
1863 }
1864
1865 #[test]
1868 fn strip_newlines_removes_newlines_and_cr() {
1869 assert_eq!(strip_newlines("a\nb\rc\r\nd"), "abcd");
1870 }
1871
1872 #[test]
1873 fn strip_newlines_removes_literal_backslash_s() {
1874 assert_eq!(strip_newlines("abc\\sdef"), "abcdef");
1875 }
1876
1877 #[test]
1878 fn strip_newlines_preserves_normal_content() {
1879 assert_eq!(strip_newlines("<tag>value</tag>"), "<tag>value</tag>");
1880 }
1881}