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 b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
349 FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
350 })?;
351
352 let raw = format!(
353 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
354 <nfeProcB2B>{nfe_proc_content}{b2b_content}</nfeProcB2B>"
355 );
356
357 let cleaned = strip_newlines(&raw);
363 Ok(cleaned)
364}
365
366fn strip_newlines(s: &str) -> String {
373 let mut result = String::with_capacity(s.len());
374 let mut chars = s.chars().peekable();
375 while let Some(c) = chars.next() {
376 if c == '\n' || c == '\r' {
377 continue;
378 }
379 if c == '\\' {
380 if let Some(&'s') = chars.peek() {
381 chars.next(); continue;
383 }
384 }
385 result.push(c);
386 }
387 result
388}
389
390pub fn to_authorize(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
412 if request_xml.is_empty() {
413 return Err(FiscalError::XmlParsing(
414 "Erro ao protocolar: o XML a protocolar está vazio.".into(),
415 ));
416 }
417 if response_xml.is_empty() {
418 return Err(FiscalError::XmlParsing(
419 "Erro ao protocolar: o retorno da SEFAZ está vazio.".into(),
420 ));
421 }
422
423 if contains_xml_tag(request_xml, "NFe") {
429 attach_protocol(request_xml, response_xml)
430 } else if contains_xml_tag(request_xml, "envEvento") {
431 attach_event_protocol(request_xml, response_xml)
432 } else if contains_xml_tag(request_xml, "inutNFe") {
433 attach_inutilizacao(request_xml, response_xml)
434 } else {
435 Err(FiscalError::XmlParsing(
436 "Tipo de documento não reconhecido para protocolação".into(),
437 ))
438 }
439}
440
441fn contains_xml_tag(xml: &str, tag_name: &str) -> bool {
443 let pattern = format!("<{tag_name}");
444 for (i, _) in xml.match_indices(&pattern) {
445 let after = i + pattern.len();
446 if after >= xml.len() {
447 return true;
448 }
449 let next = xml.as_bytes()[after];
450 if next == b' '
451 || next == b'>'
452 || next == b'/'
453 || next == b'\n'
454 || next == b'\r'
455 || next == b'\t'
456 {
457 return true;
458 }
459 }
460 false
461}
462
463const EVT_CANCELA: &str = "110111";
465const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
467
468const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
474
475pub fn attach_cancellation(
501 nfe_proc_xml: &str,
502 cancel_event_xml: &str,
503) -> Result<String, FiscalError> {
504 let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
506 FiscalError::XmlParsing(
507 "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
508 )
509 })?;
510
511 let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
512 .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
513
514 let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
516
517 let mut matched_ret_evento: Option<&str> = None;
518
519 for ret_evento in &ret_eventos {
520 let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
521 Some(v) => v,
522 None => continue,
523 };
524 let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
525 Some(v) => v,
526 None => continue,
527 };
528 let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
529 Some(v) => v,
530 None => continue,
531 };
532
533 if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
534 && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
535 && ch_nfe_evento == ch_nfe
536 {
537 matched_ret_evento = Some(ret_evento.as_str());
538 break;
539 }
540 }
541
542 dom_reserialize_nfe_proc(nfe_proc_xml, matched_ret_evento)
546}
547
548fn dom_reserialize_nfe_proc(
565 nfe_proc_xml: &str,
566 extra_child: Option<&str>,
567) -> Result<String, FiscalError> {
568 let encoding = extract_xml_declaration_encoding(nfe_proc_xml);
570
571 let xml_decl = match &encoding {
573 Some(enc) => format!("<?xml version=\"1.0\" encoding=\"{enc}\"?>"),
574 None => "<?xml version=\"1.0\"?>".to_string(),
575 };
576
577 let nfe_proc_full = extract_tag(nfe_proc_xml, "nfeProc")
580 .ok_or_else(|| FiscalError::XmlParsing("Could not find <nfeProc> in NF-e XML".into()))?;
581
582 let versao = extract_attribute(&nfe_proc_full, "nfeProc", "versao")
584 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
585 let xmlns = extract_attribute(&nfe_proc_full, "nfeProc", "xmlns")
586 .unwrap_or_else(|| NFE_NAMESPACE.to_string());
587
588 let inner = extract_tag_inner_content(&nfe_proc_full, "nfeProc").ok_or_else(|| {
591 FiscalError::XmlParsing("Could not extract <nfeProc> inner content".into())
592 })?;
593
594 let mut result = String::with_capacity(
596 xml_decl.len() + 1 + 60 + inner.len() + extra_child.map_or(0, |c| c.len()) + 12,
597 );
598 result.push_str(&xml_decl);
599 result.push('\n');
600 result.push_str(&format!("<nfeProc xmlns=\"{xmlns}\" versao=\"{versao}\">"));
601 result.push_str(inner);
602 if let Some(child) = extra_child {
603 result.push_str(child);
604 }
605 result.push_str("</nfeProc>\n");
606
607 Ok(result)
608}
609
610fn extract_xml_declaration_encoding(xml: &str) -> Option<String> {
615 let decl_start = xml.find("<?xml ")?;
616 let decl_end = xml[decl_start..].find("?>")? + decl_start;
617 let decl = &xml[decl_start..decl_end + 2];
618
619 let enc_pat = "encoding=\"";
620 let enc_start = decl.find(enc_pat)? + enc_pat.len();
621 let enc_end = decl[enc_start..].find('"')? + enc_start;
622 Some(decl[enc_start..enc_end].to_string())
623}
624
625fn extract_tag_inner_content<'a>(xml: &'a str, tag_name: &str) -> Option<&'a str> {
628 let open_pattern = format!("<{tag_name}");
629 let start = xml.find(&open_pattern)?;
630
631 let gt_pos = xml[start..].find('>')? + start;
633
634 let close_tag = format!("</{tag_name}>");
635 let close_pos = xml.rfind(&close_tag)?;
636
637 if gt_pos + 1 > close_pos {
638 return Some("");
639 }
640
641 Some(&xml[gt_pos + 1..close_pos])
642}
643
644fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
654 format!(
655 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
656 <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
657 {first}{second}</{node_name}>"
658 )
659}
660
661fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
667 let open_pattern = format!("<{tag_name}");
669 let start = xml.find(&open_pattern)?;
670
671 let after_open = start + open_pattern.len();
674 if after_open < xml.len() {
675 let next_char = xml.as_bytes()[after_open];
676 if next_char != b' '
677 && next_char != b'>'
678 && next_char != b'/'
679 && next_char != b'\n'
680 && next_char != b'\r'
681 && next_char != b'\t'
682 {
683 return None;
684 }
685 }
686
687 let close_tag = format!("</{tag_name}>");
688 let close_index = xml.rfind(&close_tag)?;
689
690 Some(xml[start..close_index + close_tag.len()].to_string())
691}
692
693fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
696 let mut results = Vec::new();
697 let open_pattern = format!("<{tag_name}");
698 let close_tag = format!("</{tag_name}>");
699 let mut search_from = 0;
700
701 while search_from < xml.len() {
702 let start = match xml[search_from..].find(&open_pattern) {
703 Some(pos) => search_from + pos,
704 None => break,
705 };
706
707 let after_open = start + open_pattern.len();
709 if after_open < xml.len() {
710 let next_char = xml.as_bytes()[after_open];
711 if next_char != b' '
712 && next_char != b'>'
713 && next_char != b'/'
714 && next_char != b'\n'
715 && next_char != b'\r'
716 && next_char != b'\t'
717 {
718 search_from = after_open;
719 continue;
720 }
721 }
722
723 let end = match xml[start..].find(&close_tag) {
724 Some(pos) => start + pos + close_tag.len(),
725 None => break,
726 };
727
728 results.push(xml[start..end].to_string());
729 search_from = end;
730 }
731
732 results
733}
734
735fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
738 let open = format!("<{tag_name}");
739 let start = xml.find(&open)?;
740
741 let tag_end = xml[start..].find('>')? + start;
743 let tag_header = &xml[start..tag_end];
744
745 let attr_pattern = format!("{attr_name}=\"");
747 let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
748 let attr_end = tag_header[attr_start..].find('"')? + attr_start;
749
750 Some(tag_header[attr_start..attr_end].to_string())
751}
752
753fn extract_inf_nfe_id(xml: &str) -> Option<String> {
756 let attr_val = extract_attribute(xml, "infNFe", "Id")?;
757 Some(
758 attr_val
759 .strip_prefix("NFe")
760 .unwrap_or(&attr_val)
761 .to_string(),
762 )
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn extract_tag_finds_outermost_match() {
771 let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
772 let result = extract_tag(xml, "NFe").unwrap();
773 assert!(result.starts_with("<NFe"));
774 assert!(result.ends_with("</NFe>"));
775 assert!(result.contains("<inner/>"));
776 }
777
778 #[test]
779 fn extract_tag_returns_none_for_missing_tag() {
780 let xml = "<root><other/></root>";
781 assert!(extract_tag(xml, "NFe").is_none());
782 }
783
784 #[test]
785 fn extract_tag_does_not_match_prefix() {
786 let xml = "<root><NFeExtra>data</NFeExtra></root>";
787 assert!(extract_tag(xml, "NFe").is_none());
788 }
789
790 #[test]
791 fn extract_attribute_works() {
792 let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
793 assert_eq!(
794 extract_attribute(xml, "infNFe", "versao"),
795 Some("4.00".to_string())
796 );
797 assert_eq!(
798 extract_attribute(xml, "infNFe", "Id"),
799 Some("NFe12345".to_string())
800 );
801 }
802
803 #[test]
804 fn extract_all_tags_finds_multiple() {
805 let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
806 let items = extract_all_tags(xml, "item");
807 assert_eq!(items.len(), 3);
808 assert!(items[0].contains("1"));
809 assert!(items[2].contains("3"));
810 }
811
812 #[test]
813 fn join_xml_produces_correct_wrapper() {
814 let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
815 assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
816 assert!(result.contains("<wrapper versao=\"4.00\""));
817 assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
818 assert!(result.ends_with("</wrapper>"));
819 }
820
821 #[test]
822 fn extract_inf_nfe_id_strips_prefix() {
823 let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
824 let key = extract_inf_nfe_id(xml).unwrap();
825 assert_eq!(key, "35260112345678000199650010000000011123456780");
826 }
827
828 #[test]
831 fn attach_cancellation_appends_matching_ret_evento() {
832 let nfe_proc = concat!(
833 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
834 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
835 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
836 r#"<ide/></infNFe></NFe>"#,
837 r#"<protNFe versao="4.00"><infProt>"#,
838 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
839 r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
840 r#"</infProt></protNFe>"#,
841 r#"</nfeProc>"#
842 );
843
844 let cancel_xml = concat!(
845 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
846 r#"<cStat>135</cStat>"#,
847 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
848 r#"<tpEvento>110111</tpEvento>"#,
849 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
850 r#"<nProt>135220000009999</nProt>"#,
851 r#"</infEvento></retEvento></retEnvEvento>"#
852 );
853
854 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
855
856 assert!(
858 result.contains("<retEvento"),
859 "Result should contain <retEvento>"
860 );
861 assert!(
862 result.contains("<tpEvento>110111</tpEvento>"),
863 "Result should contain cancellation event type"
864 );
865 let ret_pos = result.find("<retEvento").unwrap();
867 let close_pos = result.rfind("</nfeProc>").unwrap();
868 assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
869 assert!(result.contains("<protNFe"));
871 assert!(result.contains("<NFe>"));
872 }
873
874 #[test]
875 fn attach_cancellation_ignores_non_matching_ch_nfe() {
876 let nfe_proc = concat!(
877 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
878 r#"<NFe/>"#,
879 r#"<protNFe versao="4.00"><infProt>"#,
880 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
881 r#"<cStat>100</cStat>"#,
882 r#"</infProt></protNFe>"#,
883 r#"</nfeProc>"#
884 );
885
886 let cancel_xml = concat!(
887 r#"<retEvento versao="1.00"><infEvento>"#,
888 r#"<cStat>135</cStat>"#,
889 r#"<tpEvento>110111</tpEvento>"#,
890 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
891 r#"<nProt>135220000009999</nProt>"#,
892 r#"</infEvento></retEvento>"#
893 );
894
895 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
896 assert!(
899 !result.contains("<retEvento"),
900 "Should not contain retEvento"
901 );
902 assert!(
903 result.starts_with("<?xml version=\"1.0\"?>\n"),
904 "Should have XML declaration (no encoding since input had none)"
905 );
906 assert!(
907 result
908 .contains(r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#),
909 "Should reorder xmlns before versao"
910 );
911 }
912
913 #[test]
914 fn attach_cancellation_ignores_wrong_tp_evento() {
915 let nfe_proc = concat!(
916 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
917 r#"<NFe/>"#,
918 r#"<protNFe versao="4.00"><infProt>"#,
919 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
920 r#"<cStat>100</cStat>"#,
921 r#"</infProt></protNFe>"#,
922 r#"</nfeProc>"#
923 );
924
925 let cancel_xml = concat!(
926 r#"<retEvento versao="1.00"><infEvento>"#,
927 r#"<cStat>135</cStat>"#,
928 r#"<tpEvento>110110</tpEvento>"#, r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
930 r#"<nProt>135220000009999</nProt>"#,
931 r#"</infEvento></retEvento>"#
932 );
933
934 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
935 assert!(
937 !result.contains("<retEvento"),
938 "Should not contain retEvento"
939 );
940 assert!(
941 result.starts_with("<?xml version=\"1.0\"?>\n"),
942 "Should have XML declaration"
943 );
944 }
945
946 #[test]
947 fn attach_cancellation_ignores_rejected_status() {
948 let nfe_proc = concat!(
949 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
950 r#"<NFe/>"#,
951 r#"<protNFe versao="4.00"><infProt>"#,
952 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
953 r#"<cStat>100</cStat>"#,
954 r#"</infProt></protNFe>"#,
955 r#"</nfeProc>"#
956 );
957
958 let cancel_xml = concat!(
959 r#"<retEvento versao="1.00"><infEvento>"#,
960 r#"<cStat>573</cStat>"#, r#"<tpEvento>110111</tpEvento>"#,
962 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
963 r#"<nProt>135220000009999</nProt>"#,
964 r#"</infEvento></retEvento>"#
965 );
966
967 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
968 assert!(
970 !result.contains("<retEvento"),
971 "Should not contain retEvento"
972 );
973 assert!(
974 result.starts_with("<?xml version=\"1.0\"?>\n"),
975 "Should have XML declaration"
976 );
977 }
978
979 #[test]
980 fn attach_cancellation_accepts_status_155() {
981 let nfe_proc = concat!(
982 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
983 r#"<NFe/>"#,
984 r#"<protNFe versao="4.00"><infProt>"#,
985 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
986 r#"<cStat>100</cStat>"#,
987 r#"</infProt></protNFe>"#,
988 r#"</nfeProc>"#
989 );
990
991 let cancel_xml = concat!(
992 r#"<retEvento versao="1.00"><infEvento>"#,
993 r#"<cStat>155</cStat>"#,
994 r#"<tpEvento>110111</tpEvento>"#,
995 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
996 r#"<nProt>135220000009999</nProt>"#,
997 r#"</infEvento></retEvento>"#
998 );
999
1000 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1001 assert!(result.contains("<retEvento"));
1002 }
1003
1004 #[test]
1005 fn attach_cancellation_accepts_substituicao_110112() {
1006 let nfe_proc = concat!(
1007 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1008 r#"<NFe/>"#,
1009 r#"<protNFe versao="4.00"><infProt>"#,
1010 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1011 r#"<cStat>100</cStat>"#,
1012 r#"</infProt></protNFe>"#,
1013 r#"</nfeProc>"#
1014 );
1015
1016 let cancel_xml = concat!(
1017 r#"<retEvento versao="1.00"><infEvento>"#,
1018 r#"<cStat>135</cStat>"#,
1019 r#"<tpEvento>110112</tpEvento>"#,
1020 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1021 r#"<nProt>135220000009999</nProt>"#,
1022 r#"</infEvento></retEvento>"#
1023 );
1024
1025 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1026 assert!(
1027 result.contains("<tpEvento>110112</tpEvento>"),
1028 "Should accept cancellation by substitution"
1029 );
1030 }
1031
1032 #[test]
1033 fn attach_cancellation_rejects_missing_prot_nfe() {
1034 let nfe_xml = "<NFe><infNFe/></NFe>";
1035 let cancel_xml = "<retEvento/>";
1036 let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
1037 assert!(matches!(err, FiscalError::XmlParsing(_)));
1038 }
1039
1040 #[test]
1041 fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
1042 let nfe_proc = concat!(
1043 r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
1044 r#"<cStat>100</cStat>"#,
1045 r#"</infProt></protNFe></nfeProc>"#
1046 );
1047 let cancel_xml = "<retEvento/>";
1048 let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
1049 assert!(matches!(err, FiscalError::XmlParsing(_)));
1050 }
1051
1052 #[test]
1060 fn attach_cancellation_parity_with_php() {
1061 let nfe_proc = concat!(
1063 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1064 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1065 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1066 r#"<ide/></infNFe></NFe>"#,
1067 r#"<protNFe versao="4.00"><infProt>"#,
1068 r#"<digVal>abc</digVal>"#,
1069 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1070 r#"<cStat>100</cStat>"#,
1071 r#"<xMotivo>Autorizado</xMotivo>"#,
1072 r#"<nProt>135220000009921</nProt>"#,
1073 r#"</infProt></protNFe>"#,
1074 r#"</nfeProc>"#
1075 );
1076
1077 let cancel_xml = concat!(
1078 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1079 r#"<cStat>135</cStat>"#,
1080 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1081 r#"<tpEvento>110111</tpEvento>"#,
1082 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1083 r#"<nProt>135220000009999</nProt>"#,
1084 r#"</infEvento></retEvento></retEnvEvento>"#
1085 );
1086
1087 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1088
1089 let expected = concat!(
1095 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1096 r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
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#"<retEvento versao="1.00"><infEvento>"#,
1107 r#"<cStat>135</cStat>"#,
1108 r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1109 r#"<tpEvento>110111</tpEvento>"#,
1110 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1111 r#"<nProt>135220000009999</nProt>"#,
1112 r#"</infEvento></retEvento>"#,
1113 "</nfeProc>\n"
1114 );
1115
1116 assert_eq!(result, expected);
1117 }
1118
1119 #[test]
1121 fn attach_cancellation_no_match_still_reserializes_like_php() {
1122 let nfe_proc = concat!(
1123 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1124 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1125 r#"<NFe/>"#,
1126 r#"<protNFe versao="4.00"><infProt>"#,
1127 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1128 r#"<cStat>100</cStat>"#,
1129 r#"</infProt></protNFe>"#,
1130 r#"</nfeProc>"#
1131 );
1132
1133 let cancel_xml = concat!(
1134 r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1135 r#"<cStat>135</cStat>"#,
1136 r#"<tpEvento>110111</tpEvento>"#,
1137 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1138 r#"<nProt>135220000009999</nProt>"#,
1139 r#"</infEvento></retEvento></retEnvEvento>"#
1140 );
1141
1142 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1143
1144 let expected = concat!(
1146 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1147 r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
1148 r#"<NFe/>"#,
1149 r#"<protNFe versao="4.00"><infProt>"#,
1150 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1151 r#"<cStat>100</cStat>"#,
1152 r#"</infProt></protNFe>"#,
1153 "</nfeProc>\n"
1154 );
1155
1156 assert_eq!(result, expected);
1157 }
1158
1159 #[test]
1161 fn extract_xml_declaration_encoding_works() {
1162 assert_eq!(
1163 extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#),
1164 Some("UTF-8".to_string())
1165 );
1166 assert_eq!(
1167 extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="utf-8"?><root/>"#),
1168 Some("utf-8".to_string())
1169 );
1170 assert_eq!(
1171 extract_xml_declaration_encoding(r#"<?xml version="1.0"?><root/>"#),
1172 None
1173 );
1174 assert_eq!(extract_xml_declaration_encoding(r#"<root/>"#), None);
1175 }
1176
1177 #[test]
1178 fn extract_tag_inner_content_works() {
1179 assert_eq!(
1180 extract_tag_inner_content(r#"<root attr="val">inner content</root>"#, "root"),
1181 Some("inner content")
1182 );
1183 assert_eq!(
1184 extract_tag_inner_content(r#"<root></root>"#, "root"),
1185 Some("")
1186 );
1187 }
1188
1189 #[test]
1192 fn attach_protocol_empty_request_xml() {
1193 let err = attach_protocol("", "<protNFe/>").unwrap_err();
1194 assert!(matches!(err, FiscalError::XmlParsing(_)));
1195 }
1196
1197 #[test]
1198 fn attach_protocol_empty_response_xml() {
1199 let err = attach_protocol("<NFe/>", "").unwrap_err();
1200 assert!(matches!(err, FiscalError::XmlParsing(_)));
1201 }
1202
1203 #[test]
1204 fn attach_protocol_matching_digest_and_key() {
1205 let request = concat!(
1206 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1207 r#"<ide/></infNFe>"#,
1208 r#"<Signature><SignedInfo/><SignatureValue/>"#,
1209 r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1210 r#"</NFe>"#
1211 );
1212 let response = concat!(
1213 r#"<protNFe versao="4.00"><infProt>"#,
1214 r#"<digVal>abc123</digVal>"#,
1215 r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1216 r#"<cStat>100</cStat>"#,
1217 r#"<xMotivo>Autorizado</xMotivo>"#,
1218 r#"</infProt></protNFe>"#
1219 );
1220 let result = attach_protocol(request, response).unwrap();
1221 assert!(result.contains("<nfeProc"));
1222 assert!(result.contains("</nfeProc>"));
1223 assert!(result.contains("<NFe>"));
1224 assert!(result.contains("<protNFe"));
1225 }
1226
1227 #[test]
1228 fn attach_protocol_rejected_status_in_exact_match() {
1229 let request = concat!(
1230 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1231 r#"<ide/></infNFe>"#,
1232 r#"<Signature><SignedInfo/><SignatureValue/>"#,
1233 r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1234 r#"</NFe>"#
1235 );
1236 let response = concat!(
1237 r#"<protNFe versao="4.00"><infProt>"#,
1238 r#"<digVal>abc123</digVal>"#,
1239 r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1240 r#"<cStat>999</cStat>"#,
1241 r#"<xMotivo>Rejeitada</xMotivo>"#,
1242 r#"</infProt></protNFe>"#
1243 );
1244 let err = attach_protocol(request, response).unwrap_err();
1245 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1246 }
1247
1248 #[test]
1249 fn attach_protocol_fallback_rejected_status() {
1250 let request = concat!(
1252 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1253 r#"<ide/></infNFe></NFe>"#
1254 );
1255 let response = concat!(
1256 r#"<protNFe versao="4.00"><infProt>"#,
1257 r#"<cStat>999</cStat>"#,
1258 r#"<xMotivo>Rejeitada</xMotivo>"#,
1259 r#"</infProt></protNFe>"#
1260 );
1261 let err = attach_protocol(request, response).unwrap_err();
1262 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1263 }
1264
1265 #[test]
1268 fn attach_inutilizacao_empty_request() {
1269 let err = attach_inutilizacao("", "<retInutNFe/>").unwrap_err();
1270 assert!(matches!(err, FiscalError::XmlParsing(_)));
1271 }
1272
1273 #[test]
1274 fn attach_inutilizacao_empty_response() {
1275 let err = attach_inutilizacao("<inutNFe/>", "").unwrap_err();
1276 assert!(matches!(err, FiscalError::XmlParsing(_)));
1277 }
1278
1279 #[test]
1280 fn attach_inutilizacao_missing_inut_tag() {
1281 let err = attach_inutilizacao("<other/>", "<retInutNFe><cStat>102</cStat></retInutNFe>")
1282 .unwrap_err();
1283 assert!(matches!(err, FiscalError::XmlParsing(_)));
1284 }
1285
1286 #[test]
1287 fn attach_inutilizacao_missing_ret_tag() {
1288 let err = attach_inutilizacao(r#"<inutNFe versao="4.00"><data/></inutNFe>"#, "<other/>")
1289 .unwrap_err();
1290 assert!(matches!(err, FiscalError::XmlParsing(_)));
1291 }
1292
1293 #[test]
1294 fn attach_inutilizacao_rejected_status() {
1295 let err = attach_inutilizacao(
1296 r#"<inutNFe versao="4.00"><data/></inutNFe>"#,
1297 r#"<retInutNFe><cStat>999</cStat><xMotivo>Erro</xMotivo></retInutNFe>"#,
1298 )
1299 .unwrap_err();
1300 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1301 }
1302
1303 #[test]
1304 fn attach_inutilizacao_success() {
1305 let result = attach_inutilizacao(
1306 r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#,
1307 r#"<retInutNFe><cStat>102</cStat><xMotivo>Inutilizacao de numero homologado</xMotivo></retInutNFe>"#,
1308 )
1309 .unwrap();
1310 assert!(result.contains("<ProcInutNFe"));
1311 assert!(result.contains("<inutNFe"));
1312 assert!(result.contains("<retInutNFe>"));
1313 }
1314
1315 #[test]
1318 fn attach_event_protocol_empty_request() {
1319 let err = attach_event_protocol("", "<retEvento/>").unwrap_err();
1320 assert!(matches!(err, FiscalError::XmlParsing(_)));
1321 }
1322
1323 #[test]
1324 fn attach_event_protocol_empty_response() {
1325 let err = attach_event_protocol("<evento/>", "").unwrap_err();
1326 assert!(matches!(err, FiscalError::XmlParsing(_)));
1327 }
1328
1329 #[test]
1330 fn attach_event_protocol_missing_evento() {
1331 let err = attach_event_protocol(
1332 "<other/>",
1333 "<retEvento><infEvento><cStat>135</cStat></infEvento></retEvento>",
1334 )
1335 .unwrap_err();
1336 assert!(matches!(err, FiscalError::XmlParsing(_)));
1337 }
1338
1339 #[test]
1340 fn attach_event_protocol_missing_ret_evento() {
1341 let err =
1342 attach_event_protocol(r#"<evento versao="1.00"><infEvento/></evento>"#, "<other/>")
1343 .unwrap_err();
1344 assert!(matches!(err, FiscalError::XmlParsing(_)));
1345 }
1346
1347 #[test]
1348 fn attach_event_protocol_rejected_status() {
1349 let err = attach_event_protocol(
1350 r#"<evento versao="1.00"><infEvento/></evento>"#,
1351 r#"<retEvento><infEvento><cStat>999</cStat><xMotivo>Rejeitado</xMotivo></infEvento></retEvento>"#,
1352 )
1353 .unwrap_err();
1354 assert!(matches!(err, FiscalError::SefazRejection { .. }));
1355 }
1356
1357 #[test]
1358 fn attach_event_protocol_success() {
1359 let request = concat!(
1360 r#"<envEvento><idLote>100</idLote>"#,
1361 r#"<evento versao="1.00"><infEvento Id="ID1234"/></evento>"#,
1362 r#"</envEvento>"#
1363 );
1364 let response = concat!(
1365 r#"<retEnvEvento><idLote>100</idLote>"#,
1366 r#"<retEvento><infEvento><cStat>135</cStat>"#,
1367 r#"<xMotivo>Evento registrado</xMotivo>"#,
1368 r#"</infEvento></retEvento></retEnvEvento>"#
1369 );
1370 let result = attach_event_protocol(request, response).unwrap();
1371 assert!(result.contains("<procEventoNFe"));
1372 assert!(result.contains("<evento"));
1373 assert!(result.contains("<retEvento>"));
1374 }
1375
1376 #[test]
1379 fn attach_b2b_no_nfe_proc() {
1380 let err = attach_b2b("<NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1381 assert!(matches!(err, FiscalError::XmlParsing(_)));
1382 }
1383
1384 #[test]
1385 fn attach_b2b_no_b2b_tag() {
1386 let err = attach_b2b("<nfeProc><NFe/></nfeProc>", "<other>data</other>", None).unwrap_err();
1387 assert!(matches!(err, FiscalError::XmlParsing(_)));
1388 }
1389
1390 #[test]
1391 fn attach_b2b_extract_failure() {
1392 let err = attach_b2b("<nfeProc><NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1394 assert!(matches!(err, FiscalError::XmlParsing(_)));
1395 }
1396
1397 #[test]
1398 fn attach_b2b_success() {
1399 let result = attach_b2b(
1400 "<nfeProc><NFe/><protNFe/></nfeProc>",
1401 "<NFeB2BFin><tag>data</tag></NFeB2BFin>",
1402 None,
1403 )
1404 .unwrap();
1405 assert!(result.contains("<nfeProcB2B>"));
1406 assert!(result.contains("<nfeProc>"));
1407 assert!(result.contains("<NFeB2BFin>"));
1408 }
1409
1410 #[test]
1411 fn attach_b2b_custom_tag() {
1412 let result = attach_b2b(
1413 "<nfeProc><NFe/><protNFe/></nfeProc>",
1414 "<CustomB2B><tag>data</tag></CustomB2B>",
1415 Some("CustomB2B"),
1416 )
1417 .unwrap();
1418 assert!(result.contains("<CustomB2B>"));
1419 }
1420
1421 #[test]
1424 fn extract_all_tags_skips_prefix_match() {
1425 let xml = "<root><protNFeExtra>bad</protNFeExtra><protNFe>good</protNFe></root>";
1427 let results = extract_all_tags(xml, "protNFe");
1428 assert_eq!(results.len(), 1);
1429 assert!(results[0].contains("good"));
1430 }
1431
1432 #[test]
1433 fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
1434 let nfe_proc = concat!(
1435 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1436 r#"<NFe/>"#,
1437 r#"<protNFe versao="4.00"><infProt>"#,
1438 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1439 r#"<cStat>100</cStat>"#,
1440 r#"</infProt></protNFe>"#,
1441 r#"</nfeProc>"#
1442 );
1443
1444 let cancel_xml = concat!(
1445 r#"<retEnvEvento>"#,
1446 r#"<retEvento versao="1.00"><infEvento>"#,
1448 r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1449 r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1450 r#"<nProt>111111111111111</nProt>"#,
1451 r#"</infEvento></retEvento>"#,
1452 r#"<retEvento versao="1.00"><infEvento>"#,
1454 r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1455 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1456 r#"<nProt>222222222222222</nProt>"#,
1457 r#"</infEvento></retEvento>"#,
1458 r#"</retEnvEvento>"#
1459 );
1460
1461 let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1462 assert!(result.contains("<nProt>222222222222222</nProt>"));
1463 assert_eq!(result.matches("<retEvento").count(), 1);
1465 }
1466
1467 #[test]
1470 fn to_authorize_empty_request_returns_error() {
1471 let err = to_authorize("", "<retEnviNFe/>").unwrap_err();
1472 assert!(matches!(err, FiscalError::XmlParsing(_)));
1473 }
1474
1475 #[test]
1476 fn to_authorize_empty_response_returns_error() {
1477 let err = to_authorize("<NFe/>", "").unwrap_err();
1478 assert!(matches!(err, FiscalError::XmlParsing(_)));
1479 }
1480
1481 #[test]
1482 fn to_authorize_unrecognized_document_returns_error() {
1483 let err = to_authorize("<other>data</other>", "<response/>").unwrap_err();
1484 let msg = format!("{err}");
1485 assert!(
1486 msg.contains("não reconhecido"),
1487 "should mention unrecognized type: {msg}"
1488 );
1489 }
1490
1491 #[test]
1492 fn contains_xml_tag_basic() {
1493 assert!(contains_xml_tag("<NFe versao=\"4.00\">", "NFe"));
1494 assert!(contains_xml_tag("<NFe>", "NFe"));
1495 assert!(contains_xml_tag("<NFe/>", "NFe"));
1496 assert!(!contains_xml_tag("<NFeExtra>", "NFe"));
1497 assert!(contains_xml_tag("<envEvento versao=\"1.00\">", "envEvento"));
1498 assert!(contains_xml_tag("<inutNFe versao=\"4.00\">", "inutNFe"));
1499 }
1500
1501 #[test]
1504 fn attach_b2b_strips_newlines() {
1505 let nfe_proc = "<nfeProc versao=\"4.00\">\n<NFe/>\n<protNFe/>\n</nfeProc>";
1506 let b2b = "<NFeB2BFin>\n<data>test</data>\n</NFeB2BFin>";
1507 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1508 assert!(!result.contains('\n'), "Result should not contain newlines");
1509 assert!(
1510 !result.contains('\r'),
1511 "Result should not contain carriage returns"
1512 );
1513 assert!(result.contains("<nfeProcB2B>"));
1514 assert!(result.contains("<NFeB2BFin>"));
1515 }
1516
1517 #[test]
1518 fn attach_b2b_strips_carriage_returns() {
1519 let nfe_proc = "<nfeProc versao=\"4.00\">\r\n<NFe/>\r\n</nfeProc>";
1520 let b2b = "<NFeB2BFin><data>test</data></NFeB2BFin>";
1521 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1522 assert!(!result.contains('\r'));
1523 assert!(!result.contains('\n'));
1524 }
1525
1526 #[test]
1529 fn attach_protocol_fallback_prot_invalid_status() {
1530 let request = concat!(
1532 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1533 r#"<DigestValue>abc123</DigestValue>"#,
1534 r#"</infNFe></NFe>"#
1535 );
1536 let response = concat!(
1539 r#"<protNFe versao="4.00"><infProt>"#,
1540 r#"<cStat>999</cStat>"#,
1541 r#"<xMotivo>Rejeitado</xMotivo>"#,
1542 r#"</infProt></protNFe>"#
1543 );
1544 let err = attach_protocol(request, response).unwrap_err();
1545 match err {
1546 FiscalError::SefazRejection { code, .. } => assert_eq!(code, "999"),
1547 other => panic!("Expected SefazRejection, got {:?}", other),
1548 }
1549 }
1550
1551 #[test]
1554 fn attach_inutilizacao_version_mismatch() {
1555 let request = concat!(
1556 r#"<inutNFe versao="4.00"><infInut>"#,
1557 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1558 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1559 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1560 r#"</infInut></inutNFe>"#
1561 );
1562 let response = concat!(
1563 r#"<retInutNFe versao="3.10"><infInut>"#,
1564 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1565 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1566 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1567 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1568 r#"</infInut></retInutNFe>"#
1569 );
1570 let err = attach_inutilizacao(request, response).unwrap_err();
1571 match err {
1572 FiscalError::XmlParsing(msg) => {
1573 assert!(
1574 msg.contains("versao"),
1575 "Expected version mismatch error, got: {msg}"
1576 );
1577 }
1578 other => panic!("Expected XmlParsing, got {:?}", other),
1579 }
1580 }
1581
1582 #[test]
1585 fn attach_inutilizacao_tag_value_mismatch() {
1586 let request = concat!(
1587 r#"<inutNFe versao="4.00"><infInut>"#,
1588 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1589 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1590 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1591 r#"</infInut></inutNFe>"#
1592 );
1593 let response = concat!(
1594 r#"<retInutNFe versao="4.00"><infInut>"#,
1595 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1596 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1597 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>2</serie>"#,
1598 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1599 r#"</infInut></retInutNFe>"#
1600 );
1601 let err = attach_inutilizacao(request, response).unwrap_err();
1602 match err {
1603 FiscalError::XmlParsing(msg) => {
1604 assert!(
1605 msg.contains("serie"),
1606 "Expected serie mismatch error, got: {msg}"
1607 );
1608 }
1609 other => panic!("Expected XmlParsing, got {:?}", other),
1610 }
1611 }
1612
1613 #[test]
1616 fn attach_event_protocol_id_lote_mismatch() {
1617 let request = concat!(
1618 r#"<envEvento><idLote>100</idLote>"#,
1619 r#"<evento versao="1.00"><infEvento>"#,
1620 r#"<tpEvento>110110</tpEvento>"#,
1621 r#"</infEvento></evento></envEvento>"#
1622 );
1623 let response = concat!(
1624 r#"<retEnvEvento><idLote>999</idLote>"#,
1625 r#"<retEvento versao="1.00"><infEvento>"#,
1626 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1627 r#"<tpEvento>110110</tpEvento>"#,
1628 r#"</infEvento></retEvento></retEnvEvento>"#
1629 );
1630 let err = attach_event_protocol(request, response).unwrap_err();
1631 match err {
1632 FiscalError::XmlParsing(msg) => {
1633 assert!(
1634 msg.contains("lote"),
1635 "Expected lote mismatch error, got: {msg}"
1636 );
1637 }
1638 other => panic!("Expected XmlParsing, got {:?}", other),
1639 }
1640 }
1641
1642 #[test]
1645 fn attach_event_protocol_both_invalid_reports_cstat_first() {
1646 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>573</cStat><xMotivo>Duplicidade de evento</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::SefazRejection { code, message } => {
1665 assert_eq!(code, "573");
1666 assert_eq!(message, "Duplicidade de evento");
1667 }
1668 other => panic!("Expected SefazRejection (cStat first), got {:?}", other),
1669 }
1670 }
1671
1672 #[test]
1675 fn attach_event_protocol_missing_id_lote_in_request() {
1676 let request = concat!(
1677 r#"<envEvento>"#,
1678 r#"<evento versao="1.00"><infEvento>"#,
1679 r#"<tpEvento>110110</tpEvento>"#,
1680 r#"</infEvento></evento></envEvento>"#
1681 );
1682 let response = concat!(
1683 r#"<retEnvEvento><idLote>100</idLote>"#,
1684 r#"<retEvento versao="1.00"><infEvento>"#,
1685 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1686 r#"<tpEvento>110110</tpEvento>"#,
1687 r#"</infEvento></retEvento></retEnvEvento>"#
1688 );
1689 let err = attach_event_protocol(request, response).unwrap_err();
1690 match err {
1691 FiscalError::XmlParsing(msg) => {
1692 assert_eq!(msg, "idLote not found in request XML");
1693 }
1694 other => panic!("Expected XmlParsing, got {:?}", other),
1695 }
1696 }
1697
1698 #[test]
1699 fn attach_event_protocol_missing_id_lote_in_response() {
1700 let request = concat!(
1701 r#"<envEvento><idLote>100</idLote>"#,
1702 r#"<evento versao="1.00"><infEvento>"#,
1703 r#"<tpEvento>110110</tpEvento>"#,
1704 r#"</infEvento></evento></envEvento>"#
1705 );
1706 let response = concat!(
1707 r#"<retEnvEvento>"#,
1708 r#"<retEvento versao="1.00"><infEvento>"#,
1709 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1710 r#"<tpEvento>110110</tpEvento>"#,
1711 r#"</infEvento></retEvento></retEnvEvento>"#
1712 );
1713 let err = attach_event_protocol(request, response).unwrap_err();
1714 match err {
1715 FiscalError::XmlParsing(msg) => {
1716 assert_eq!(msg, "idLote not found in response XML");
1717 }
1718 other => panic!("Expected XmlParsing, got {:?}", other),
1719 }
1720 }
1721
1722 #[test]
1723 fn attach_event_protocol_missing_id_lote_in_both() {
1724 let request = concat!(
1725 r#"<envEvento>"#,
1726 r#"<evento versao="1.00"><infEvento>"#,
1727 r#"<tpEvento>110110</tpEvento>"#,
1728 r#"</infEvento></evento></envEvento>"#
1729 );
1730 let response = concat!(
1731 r#"<retEnvEvento>"#,
1732 r#"<retEvento versao="1.00"><infEvento>"#,
1733 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1734 r#"<tpEvento>110110</tpEvento>"#,
1735 r#"</infEvento></retEvento></retEnvEvento>"#
1736 );
1737 let err = attach_event_protocol(request, response).unwrap_err();
1738 match err {
1739 FiscalError::XmlParsing(msg) => {
1740 assert_eq!(msg, "idLote not found in request XML");
1741 }
1742 other => panic!("Expected XmlParsing, got {:?}", other),
1743 }
1744 }
1745
1746 #[test]
1749 fn attach_b2b_extract_tag_coverage() {
1750 let nfe_proc = concat!(
1751 r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1752 r#"<NFe><infNFe/></NFe><protNFe><infProt/></protNFe>"#,
1753 r#"</nfeProc>"#
1754 );
1755 let b2b = r#"<NFeB2BFin versao="1.00"><dados>value</dados></NFeB2BFin>"#;
1756 let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1757 assert!(result.contains("<nfeProcB2B>"));
1758 assert!(result.contains("<dados>value</dados>"));
1759 }
1760
1761 #[test]
1764 fn to_authorize_dispatches_nfe() {
1765 let request = concat!(
1766 r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1767 r#"<DigestValue>abc</DigestValue>"#,
1768 r#"</infNFe></NFe>"#
1769 );
1770 let response = concat!(
1771 r#"<protNFe versao="4.00"><infProt>"#,
1772 r#"<cStat>100</cStat><xMotivo>OK</xMotivo>"#,
1773 r#"<digVal>abc</digVal>"#,
1774 r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1775 r#"</infProt></protNFe>"#
1776 );
1777 let result = to_authorize(request, response).unwrap();
1778 assert!(result.contains("<nfeProc"));
1779 }
1780
1781 #[test]
1784 fn to_authorize_dispatches_env_evento() {
1785 let request = concat!(
1786 r#"<envEvento><idLote>1</idLote>"#,
1787 r#"<evento versao="1.00"><infEvento>"#,
1788 r#"<tpEvento>110110</tpEvento>"#,
1789 r#"</infEvento></evento></envEvento>"#
1790 );
1791 let response = concat!(
1792 r#"<retEnvEvento><idLote>1</idLote>"#,
1793 r#"<retEvento versao="1.00"><infEvento>"#,
1794 r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1795 r#"<tpEvento>110110</tpEvento>"#,
1796 r#"</infEvento></retEvento></retEnvEvento>"#
1797 );
1798 let result = to_authorize(request, response).unwrap();
1799 assert!(result.contains("<procEventoNFe"));
1800 }
1801
1802 #[test]
1805 fn to_authorize_dispatches_inut_nfe() {
1806 let request = concat!(
1807 r#"<inutNFe versao="4.00"><infInut>"#,
1808 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1809 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1810 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1811 r#"</infInut></inutNFe>"#
1812 );
1813 let response = concat!(
1814 r#"<retInutNFe versao="4.00"><infInut>"#,
1815 r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1816 r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1817 r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1818 r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1819 r#"</infInut></retInutNFe>"#
1820 );
1821 let result = to_authorize(request, response).unwrap();
1822 assert!(result.contains("<ProcInutNFe"));
1823 }
1824
1825 #[test]
1828 fn contains_xml_tag_at_end_of_string() {
1829 assert!(contains_xml_tag("<NFe", "NFe"));
1831 }
1832
1833 #[test]
1836 fn strip_newlines_removes_newlines_and_cr() {
1837 assert_eq!(strip_newlines("a\nb\rc\r\nd"), "abcd");
1838 }
1839
1840 #[test]
1841 fn strip_newlines_removes_literal_backslash_s() {
1842 assert_eq!(strip_newlines("abc\\sdef"), "abcdef");
1843 }
1844
1845 #[test]
1846 fn strip_newlines_preserves_normal_content() {
1847 assert_eq!(strip_newlines("<tag>value</tag>"), "<tag>value</tag>");
1848 }
1849}