1use crate::FiscalError;
2use crate::newtypes::IbgeCode;
3use crate::types::{AccessKeyParams, ContingencyType, EmissionType, InvoiceModel};
4use crate::xml_builder::access_key::build_access_key;
5use crate::xml_utils::extract_xml_tag_value;
6
7#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub struct Contingency {
26 pub contingency_type: Option<ContingencyType>,
28 pub reason: Option<String>,
30 pub activated_at: Option<String>,
32 pub timestamp: u64,
34}
35
36impl Contingency {
37 pub fn new() -> Self {
39 Self {
40 contingency_type: None,
41 reason: None,
42 activated_at: None,
43 timestamp: 0,
44 }
45 }
46
47 pub fn is_active(&self) -> bool {
49 self.contingency_type.is_some()
50 }
51
52 pub fn activate(
63 &mut self,
64 contingency_type: ContingencyType,
65 reason: &str,
66 ) -> Result<(), FiscalError> {
67 let trimmed = reason.trim();
68 let len = trimmed.chars().count();
69 if !(15..=255).contains(&len) {
70 return Err(FiscalError::Contingency(
71 "The justification for entering contingency mode must be between 15 and 255 UTF-8 characters.".to_string(),
72 ));
73 }
74
75 let now = std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .unwrap_or_default()
79 .as_secs();
80
81 self.contingency_type = Some(contingency_type);
82 self.reason = Some(trimmed.to_string());
83 self.timestamp = now;
84 self.activated_at = Some(
85 chrono::DateTime::from_timestamp(now as i64, 0)
86 .unwrap_or_default()
87 .to_rfc3339(),
88 );
89 Ok(())
90 }
91
92 pub fn deactivate(&mut self) {
94 self.contingency_type = None;
95 self.reason = None;
96 self.activated_at = None;
97 self.timestamp = 0;
98 }
99
100 pub fn load(json: &str) -> Result<Self, FiscalError> {
116 let motive = extract_json_string(json, "motive")
118 .ok_or_else(|| FiscalError::Contingency("Missing 'motive' in JSON".to_string()))?;
119 let timestamp = extract_json_number(json, "timestamp")
120 .ok_or_else(|| FiscalError::Contingency("Missing 'timestamp' in JSON".to_string()))?;
121 let type_str = extract_json_string(json, "type")
122 .ok_or_else(|| FiscalError::Contingency("Missing 'type' in JSON".to_string()))?;
123 let tp_emis = extract_json_number(json, "tpEmis")
124 .ok_or_else(|| FiscalError::Contingency("Missing 'tpEmis' in JSON".to_string()))?;
125
126 let contingency_type = ContingencyType::from_type_str(&type_str);
127
128 if !type_str.is_empty() && contingency_type.is_none() {
130 return Err(FiscalError::Contingency(format!(
131 "Unrecognized contingency type: {type_str}"
132 )));
133 }
134
135 let _ = tp_emis; Ok(Self {
138 contingency_type,
139 reason: if motive.is_empty() {
140 None
141 } else {
142 Some(motive)
143 },
144 activated_at: if timestamp > 0 {
145 Some(
146 chrono::DateTime::from_timestamp(timestamp as i64, 0)
147 .unwrap_or_default()
148 .to_rfc3339(),
149 )
150 } else {
151 None
152 },
153 timestamp,
154 })
155 }
156
157 pub fn to_json(&self) -> String {
169 let motive = self.reason.as_deref().unwrap_or("");
170 let type_str = self
171 .contingency_type
172 .map(|ct| ct.to_type_str())
173 .unwrap_or("");
174 let tp_emis = self.emission_type();
175 format!(
176 r#"{{"motive":"{}","timestamp":{},"type":"{}","tpEmis":{}}}"#,
177 escape_json_string(motive),
178 self.timestamp,
179 type_str,
180 tp_emis
181 )
182 }
183
184 pub fn emission_type(&self) -> u8 {
190 match self.contingency_type {
191 Some(ct) => ct.tp_emis(),
192 None => 1,
193 }
194 }
195
196 pub fn emission_type_enum(&self) -> EmissionType {
198 match self.contingency_type {
199 Some(ContingencyType::SvcAn) => EmissionType::SvcAn,
200 Some(ContingencyType::SvcRs) => EmissionType::SvcRs,
201 Some(ContingencyType::Epec) => EmissionType::Epec,
202 Some(ContingencyType::FsDa) => EmissionType::FsDa,
203 Some(ContingencyType::FsIa) => EmissionType::FsIa,
204 Some(ContingencyType::Offline) => EmissionType::Offline,
205 None => EmissionType::Normal,
206 }
207 }
208
209 pub fn check_web_service_availability(&self, model: InvoiceModel) -> Result<(), FiscalError> {
223 let ct = match self.contingency_type {
224 Some(ct) => ct,
225 None => return Ok(()),
226 };
227
228 if model == InvoiceModel::Nfce
229 && matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs)
230 {
231 return Err(FiscalError::Contingency(
232 "Não existe serviço para contingência SVCRS ou SVCAN para NFCe (modelo 65)."
233 .to_string(),
234 ));
235 }
236
237 if !matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs) {
238 return Err(FiscalError::Contingency(format!(
239 "Esse modo de contingência [{}] não possui webservice próprio, portanto não haverão envios.",
240 ct.to_type_str()
241 )));
242 }
243
244 Ok(())
245 }
246}
247
248impl Default for Contingency {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254impl core::fmt::Display for Contingency {
255 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
256 f.write_str(&self.to_json())
257 }
258}
259
260pub fn contingency_for_state(uf: &str) -> ContingencyType {
271 match uf {
272 "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => ContingencyType::SvcRs,
273 "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
274 | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => ContingencyType::SvcAn,
275 _ => panic!("Unknown state abbreviation: {uf}"),
276 }
277}
278
279pub fn try_contingency_for_state(uf: &str) -> Result<ContingencyType, FiscalError> {
288 match uf {
289 "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => Ok(ContingencyType::SvcRs),
290 "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
291 | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => Ok(ContingencyType::SvcAn),
292 _ => Err(FiscalError::InvalidStateCode(uf.to_string())),
293 }
294}
295
296pub fn adjust_nfe_contingency(xml: &str, contingency: &Contingency) -> Result<String, FiscalError> {
313 if contingency.contingency_type.is_none() {
315 return Ok(xml.to_string());
316 }
317
318 let mut xml = remove_signature(xml);
320
321 let model = extract_xml_tag_value(&xml, "mod").unwrap_or_default();
323 if model == "65" {
324 return Err(FiscalError::Contingency(
325 "The XML belongs to a model 65 document (NFC-e), incorrect for SVCAN or SVCRS contingency.".to_string(),
326 ));
327 }
328
329 let current_tp_emis = extract_xml_tag_value(&xml, "tpEmis").unwrap_or_default();
331 if current_tp_emis != "1" {
332 return Ok(xml);
334 }
335
336 let c_uf = extract_xml_tag_value(&xml, "cUF").unwrap_or_default();
338 let c_nf = extract_xml_tag_value(&xml, "cNF").unwrap_or_default();
339 let n_nf = extract_xml_tag_value(&xml, "nNF").unwrap_or_default();
340 let serie = extract_xml_tag_value(&xml, "serie").unwrap_or_default();
341 let dh_emi = extract_xml_tag_value(&xml, "dhEmi").unwrap_or_default();
342
343 let emit_doc = extract_emitter_doc(&xml);
345
346 let (year, month) = parse_year_month(&dh_emi);
348
349 let tz_offset = extract_tz_offset(&dh_emi);
351 let dth_cont = format_timestamp_with_offset(contingency.timestamp, &tz_offset);
352
353 let reason = contingency.reason.as_deref().unwrap_or("").trim();
354 let tp_emis = contingency.emission_type();
355
356 xml = xml.replacen(
358 &format!("<tpEmis>{current_tp_emis}</tpEmis>"),
359 &format!("<tpEmis>{tp_emis}</tpEmis>"),
360 1,
361 );
362
363 if xml.contains("<dhCont>") {
365 let re_start = xml.find("<dhCont>").unwrap();
367 let re_end = xml.find("</dhCont>").unwrap() + "</dhCont>".len();
368 xml = format!(
369 "{}<dhCont>{dth_cont}</dhCont>{}",
370 &xml[..re_start],
371 &xml[re_end..]
372 );
373 } else if xml.contains("<NFref>") {
374 xml = xml.replacen("<NFref>", &format!("<dhCont>{dth_cont}</dhCont><NFref>"), 1);
375 } else {
376 xml = xml.replacen("</ide>", &format!("<dhCont>{dth_cont}</dhCont></ide>"), 1);
377 }
378
379 if xml.contains("<xJust>") {
381 let re_start = xml.find("<xJust>").unwrap();
383 let re_end = xml.find("</xJust>").unwrap() + "</xJust>".len();
384 xml = format!(
385 "{}<xJust>{reason}</xJust>{}",
386 &xml[..re_start],
387 &xml[re_end..]
388 );
389 } else if xml.contains("<NFref>") {
390 xml = xml.replacen("<NFref>", &format!("<xJust>{reason}</xJust><NFref>"), 1);
391 } else {
392 xml = xml.replacen("</ide>", &format!("<xJust>{reason}</xJust></ide>"), 1);
393 }
394
395 let model_enum = match model.as_str() {
397 "65" => InvoiceModel::Nfce,
398 _ => InvoiceModel::Nfe,
399 };
400 let emission_type_enum = contingency.emission_type_enum();
401
402 let new_key = build_access_key(&AccessKeyParams {
403 state_code: IbgeCode(c_uf),
404 year_month: format!("{year}{month}"),
405 tax_id: emit_doc,
406 model: model_enum,
407 series: serie.parse().unwrap_or(0),
408 number: n_nf.parse().unwrap_or(0),
409 emission_type: emission_type_enum,
410 numeric_code: c_nf,
411 })?;
412
413 let new_cdv = &new_key[new_key.len() - 1..];
415 if let Some(start) = xml.find("<cDV>") {
417 if let Some(end) = xml[start..].find("</cDV>") {
418 let full_end = start + end + "</cDV>".len();
419 xml = format!("{}<cDV>{new_cdv}</cDV>{}", &xml[..start], &xml[full_end..]);
420 }
421 }
422
423 if let Some(id_start) = xml.find("Id=\"NFe") {
426 let after_nfe = id_start + 7; if xml.len() >= after_nfe + 44 {
429 let id_end = after_nfe + 44;
430 xml = format!("{}NFe{new_key}{}", &xml[..after_nfe], &xml[id_end..]);
431 }
432 }
433
434 Ok(xml)
435}
436
437fn remove_signature(xml: &str) -> String {
441 if let Some(start) = xml.find("<Signature") {
443 if let Some(end) = xml.find("</Signature>") {
444 let full_end = end + "</Signature>".len();
445 return format!("{}{}", xml[..start].trim_end(), &xml[full_end..])
446 .trim()
447 .to_string();
448 }
449 }
450 xml.to_string()
451}
452
453fn extract_emitter_doc(xml: &str) -> String {
455 if let Some(emit_start) = xml.find("<emit>") {
456 if let Some(emit_end) = xml.find("</emit>") {
457 let emit_block = &xml[emit_start..emit_end];
458 if let Some(cnpj) = extract_inner(emit_block, "CNPJ") {
460 return cnpj;
461 }
462 if let Some(cpf) = extract_inner(emit_block, "CPF") {
464 return cpf;
465 }
466 }
467 }
468 String::new()
469}
470
471fn extract_inner(xml: &str, tag: &str) -> Option<String> {
473 let open = format!("<{tag}>");
474 let close = format!("</{tag}>");
475 let start = xml.find(&open)? + open.len();
476 let end = xml[start..].find(&close)? + start;
477 Some(xml[start..end].to_string())
478}
479
480fn parse_year_month(dh_emi: &str) -> (String, String) {
482 if dh_emi.len() >= 7 {
483 let year = &dh_emi[2..4]; let month = &dh_emi[5..7]; (year.to_string(), month.to_string())
486 } else {
487 ("00".to_string(), "00".to_string())
488 }
489}
490
491fn extract_tz_offset(dh_emi: &str) -> String {
494 if dh_emi.len() >= 6 {
496 let tail = &dh_emi[dh_emi.len() - 6..];
497 if (tail.starts_with('+') || tail.starts_with('-')) && tail.as_bytes()[3] == b':' {
498 return tail.to_string();
499 }
500 }
501 "-03:00".to_string()
502}
503
504fn format_timestamp_with_offset(timestamp: u64, offset: &str) -> String {
506 let offset_seconds = parse_offset_seconds(offset);
508
509 if let Some(fo) = chrono::FixedOffset::east_opt(offset_seconds) {
511 if let Some(dt) = chrono::DateTime::from_timestamp(timestamp as i64, 0) {
512 let local = dt.with_timezone(&fo);
513 return local.format("%Y-%m-%dT%H:%M:%S").to_string() + offset;
514 }
515 }
516
517 format!("1970-01-01T00:00:00{offset}")
519}
520
521fn parse_offset_seconds(offset: &str) -> i32 {
523 if offset.len() < 6 {
524 return 0;
525 }
526 let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
527 let hours: i32 = offset[1..3].parse().unwrap_or(0);
528 let minutes: i32 = offset[4..6].parse().unwrap_or(0);
529 sign * (hours * 3600 + minutes * 60)
530}
531
532fn escape_json_string(s: &str) -> String {
534 let mut out = String::with_capacity(s.len());
535 for c in s.chars() {
536 match c {
537 '"' => out.push_str("\\\""),
538 '\\' => out.push_str("\\\\"),
539 '\n' => out.push_str("\\n"),
540 '\r' => out.push_str("\\r"),
541 '\t' => out.push_str("\\t"),
542 c if c.is_control() => {
543 for unit in c.encode_utf16(&mut [0; 2]) {
545 out.push_str(&format!("\\u{unit:04x}"));
546 }
547 }
548 _ => out.push(c),
549 }
550 }
551 out
552}
553
554fn extract_json_string(json: &str, key: &str) -> Option<String> {
557 let search = format!("\"{key}\"");
558 let idx = json.find(&search)?;
559 let after_key = idx + search.len();
560 let rest = json[after_key..].trim_start();
562 let rest = rest.strip_prefix(':')?;
563 let rest = rest.trim_start();
564
565 if let Some(content) = rest.strip_prefix('"') {
566 let end = content.find('"')?;
568 Some(content[..end].to_string())
569 } else {
570 None
571 }
572}
573
574fn extract_json_number(json: &str, key: &str) -> Option<u64> {
577 let search = format!("\"{key}\"");
578 let idx = json.find(&search)?;
579 let after_key = idx + search.len();
580 let rest = json[after_key..].trim_start();
581 let rest = rest.strip_prefix(':')?;
582 let rest = rest.trim_start();
583
584 let end = rest
586 .find(|c: char| !c.is_ascii_digit())
587 .unwrap_or(rest.len());
588 if end == 0 {
589 return None;
590 }
591 rest[..end].parse().ok()
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn new_contingency_is_inactive() {
600 let c = Contingency::new();
601 assert!(c.contingency_type.is_none());
602 assert!(!c.is_active());
603 assert_eq!(c.emission_type(), 1);
604 }
605
606 #[test]
607 fn default_is_inactive() {
608 let c = Contingency::default();
609 assert!(c.contingency_type.is_none());
610 assert!(!c.is_active());
611 }
612
613 #[test]
614 fn activate_sets_fields() {
615 let mut c = Contingency::new();
616 c.activate(
617 ContingencyType::SvcAn,
618 "A valid reason for contingency mode activation",
619 )
620 .unwrap();
621 assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
622 assert_eq!(c.emission_type(), 6);
623 assert!(c.is_active());
624 assert!(c.reason.is_some());
625 assert!(c.activated_at.is_some());
626 }
627
628 #[test]
629 fn activate_svc_rs() {
630 let mut c = Contingency::new();
631 c.activate(
632 ContingencyType::SvcRs,
633 "A valid reason for contingency mode activation",
634 )
635 .unwrap();
636 assert_eq!(c.emission_type(), 7);
637 assert_eq!(c.emission_type_enum(), EmissionType::SvcRs);
638 }
639
640 #[test]
641 fn activate_offline() {
642 let mut c = Contingency::new();
643 c.activate(
644 ContingencyType::Offline,
645 "A valid reason for contingency mode activation",
646 )
647 .unwrap();
648 assert_eq!(c.emission_type(), 9);
649 assert_eq!(c.emission_type_enum(), EmissionType::Offline);
650 }
651
652 #[test]
653 fn activate_epec() {
654 let mut c = Contingency::new();
655 c.activate(
656 ContingencyType::Epec,
657 "A valid reason for contingency mode activation",
658 )
659 .unwrap();
660 assert_eq!(c.emission_type(), 4);
661 assert_eq!(c.emission_type_enum(), EmissionType::Epec);
662 }
663
664 #[test]
665 fn activate_fs_da() {
666 let mut c = Contingency::new();
667 c.activate(
668 ContingencyType::FsDa,
669 "A valid reason for contingency mode activation",
670 )
671 .unwrap();
672 assert_eq!(c.emission_type(), 5);
673 assert_eq!(c.emission_type_enum(), EmissionType::FsDa);
674 }
675
676 #[test]
677 fn activate_fs_ia() {
678 let mut c = Contingency::new();
679 c.activate(
680 ContingencyType::FsIa,
681 "A valid reason for contingency mode activation",
682 )
683 .unwrap();
684 assert_eq!(c.emission_type(), 2);
685 assert_eq!(c.emission_type_enum(), EmissionType::FsIa);
686 }
687
688 #[test]
689 fn activate_rejects_short_reason() {
690 let mut c = Contingency::new();
691 let result = c.activate(ContingencyType::SvcAn, "Short");
692 assert!(result.is_err());
693 }
694
695 #[test]
696 fn activate_rejects_long_reason() {
697 let mut c = Contingency::new();
698 let motive = "A".repeat(256);
699 let result = c.activate(ContingencyType::SvcAn, &motive);
700 assert!(result.is_err());
701 }
702
703 #[test]
704 fn activate_accepts_255_char_reason() {
705 let mut c = Contingency::new();
706 let motive = "A".repeat(255);
707 let result = c.activate(ContingencyType::SvcAn, &motive);
708 assert!(result.is_ok(), "255-char reason must be accepted");
709 }
710
711 #[test]
712 fn activate_rejects_256_char_reason() {
713 let mut c = Contingency::new();
714 let motive = "A".repeat(256);
715 let result = c.activate(ContingencyType::SvcAn, &motive);
716 assert!(result.is_err(), "256-char reason must be rejected");
717 }
718
719 #[test]
720 fn deactivate_clears_state() {
721 let mut c = Contingency::new();
722 c.activate(
723 ContingencyType::SvcAn,
724 "A valid reason for contingency mode activation",
725 )
726 .unwrap();
727 c.deactivate();
728 assert!(c.contingency_type.is_none());
729 assert!(!c.is_active());
730 assert_eq!(c.emission_type(), 1);
731 assert_eq!(c.emission_type_enum(), EmissionType::Normal);
732 }
733
734 #[test]
735 fn load_from_json() {
736 let json =
737 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
738 let c = Contingency::load(json).unwrap();
739 assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
740 assert_eq!(c.emission_type(), 6);
741 assert_eq!(c.reason.as_deref(), Some("Testes Unitarios"));
742 assert!(c.is_active());
743 }
744
745 #[test]
746 fn load_svc_rs_from_json() {
747 let json =
748 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
749 let c = Contingency::load(json).unwrap();
750 assert_eq!(c.contingency_type, Some(ContingencyType::SvcRs));
751 assert_eq!(c.emission_type(), 7);
752 }
753
754 #[test]
755 fn load_epec_from_json() {
756 let json =
757 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"EPEC","tpEmis":4}"#;
758 let c = Contingency::load(json).unwrap();
759 assert_eq!(c.contingency_type, Some(ContingencyType::Epec));
760 assert_eq!(c.emission_type(), 4);
761 }
762
763 #[test]
764 fn load_fs_da_from_json() {
765 let json =
766 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSDA","tpEmis":5}"#;
767 let c = Contingency::load(json).unwrap();
768 assert_eq!(c.contingency_type, Some(ContingencyType::FsDa));
769 assert_eq!(c.emission_type(), 5);
770 }
771
772 #[test]
773 fn load_fs_ia_from_json() {
774 let json =
775 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSIA","tpEmis":2}"#;
776 let c = Contingency::load(json).unwrap();
777 assert_eq!(c.contingency_type, Some(ContingencyType::FsIa));
778 assert_eq!(c.emission_type(), 2);
779 }
780
781 #[test]
782 fn load_offline_from_json() {
783 let json =
784 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"OFFLINE","tpEmis":9}"#;
785 let c = Contingency::load(json).unwrap();
786 assert_eq!(c.contingency_type, Some(ContingencyType::Offline));
787 assert_eq!(c.emission_type(), 9);
788 }
789
790 #[test]
791 fn load_deactivated_from_json() {
792 let json = r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#;
793 let c = Contingency::load(json).unwrap();
794 assert!(c.contingency_type.is_none());
795 assert!(!c.is_active());
796 assert_eq!(c.emission_type(), 1);
797 }
798
799 #[test]
800 fn load_rejects_unknown_type() {
801 let json =
802 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"UNKNOWN","tpEmis":1}"#;
803 let result = Contingency::load(json);
804 assert!(result.is_err());
805 }
806
807 #[test]
808 fn to_json_activated() {
809 let json =
810 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
811 let c = Contingency::load(json).unwrap();
812 assert_eq!(c.to_json(), json);
813 }
814
815 #[test]
816 fn to_json_deactivated() {
817 let c = Contingency::new();
818 assert_eq!(
819 c.to_json(),
820 r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
821 );
822 }
823
824 #[test]
825 fn to_json_roundtrip() {
826 let json =
827 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
828 let c = Contingency::load(json).unwrap();
829 let output = c.to_json();
830 assert_eq!(output, json);
831 let c2 = Contingency::load(&output).unwrap();
833 assert_eq!(c2.contingency_type, c.contingency_type);
834 assert_eq!(c2.reason, c.reason);
835 assert_eq!(c2.timestamp, c.timestamp);
836 }
837
838 #[test]
839 fn deactivate_produces_correct_json() {
840 let json =
841 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
842 let mut c = Contingency::load(json).unwrap();
843 c.deactivate();
844 assert_eq!(
845 c.to_json(),
846 r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
847 );
848 }
849
850 #[test]
851 fn display_matches_to_json() {
852 let json =
853 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
854 let c = Contingency::load(json).unwrap();
855 assert_eq!(format!("{c}"), c.to_json());
856 }
857
858 #[test]
859 fn extract_json_string_works() {
860 let json = r#"{"motive":"hello world","type":"SVCAN"}"#;
861 assert_eq!(
862 extract_json_string(json, "motive"),
863 Some("hello world".to_string())
864 );
865 assert_eq!(extract_json_string(json, "type"), Some("SVCAN".to_string()));
866 }
867
868 #[test]
869 fn extract_json_number_works() {
870 let json = r#"{"timestamp":1480700623,"tpEmis":6}"#;
871 assert_eq!(extract_json_number(json, "timestamp"), Some(1480700623));
872 assert_eq!(extract_json_number(json, "tpEmis"), Some(6));
873 }
874
875 #[test]
876 fn format_timestamp_with_offset_formats_correctly() {
877 let result = format_timestamp_with_offset(1480700623, "-03:00");
879 assert_eq!(result, "2016-12-02T14:43:43-03:00");
880 }
881
882 #[test]
883 fn contingency_for_state_sp() {
884 assert_eq!(contingency_for_state("SP").as_str(), "svc-an");
885 }
886
887 #[test]
888 fn contingency_for_state_am() {
889 assert_eq!(contingency_for_state("AM").as_str(), "svc-rs");
890 }
891
892 #[test]
893 fn try_contingency_for_state_valid() {
894 assert_eq!(
895 try_contingency_for_state("SP").unwrap(),
896 ContingencyType::SvcAn
897 );
898 assert_eq!(
899 try_contingency_for_state("AM").unwrap(),
900 ContingencyType::SvcRs
901 );
902 }
903
904 #[test]
905 fn try_contingency_for_state_invalid() {
906 assert!(try_contingency_for_state("XX").is_err());
907 }
908
909 #[test]
910 fn check_web_service_nfe_svc_an_ok() {
911 let mut c = Contingency::new();
912 c.activate(
913 ContingencyType::SvcAn,
914 "A valid reason for contingency mode activation",
915 )
916 .unwrap();
917 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
918 }
919
920 #[test]
921 fn check_web_service_nfe_svc_rs_ok() {
922 let mut c = Contingency::new();
923 c.activate(
924 ContingencyType::SvcRs,
925 "A valid reason for contingency mode activation",
926 )
927 .unwrap();
928 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
929 }
930
931 #[test]
932 fn check_web_service_nfce_svc_fails() {
933 let mut c = Contingency::new();
934 c.activate(
935 ContingencyType::SvcAn,
936 "A valid reason for contingency mode activation",
937 )
938 .unwrap();
939 assert!(
940 c.check_web_service_availability(InvoiceModel::Nfce)
941 .is_err()
942 );
943 }
944
945 #[test]
946 fn check_web_service_epec_no_webservice() {
947 let mut c = Contingency::new();
948 c.activate(
949 ContingencyType::Epec,
950 "A valid reason for contingency mode activation",
951 )
952 .unwrap();
953 let err = c
954 .check_web_service_availability(InvoiceModel::Nfe)
955 .unwrap_err();
956 assert!(err.to_string().contains("EPEC"));
957 }
958
959 #[test]
960 fn check_web_service_normal_mode_ok() {
961 let c = Contingency::new();
962 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
963 assert!(c.check_web_service_availability(InvoiceModel::Nfce).is_ok());
964 }
965
966 #[test]
967 fn contingency_type_display() {
968 assert_eq!(format!("{}", ContingencyType::SvcAn), "SVCAN");
969 assert_eq!(format!("{}", ContingencyType::SvcRs), "SVCRS");
970 assert_eq!(format!("{}", ContingencyType::Epec), "EPEC");
971 assert_eq!(format!("{}", ContingencyType::FsDa), "FSDA");
972 assert_eq!(format!("{}", ContingencyType::FsIa), "FSIA");
973 assert_eq!(format!("{}", ContingencyType::Offline), "OFFLINE");
974 }
975
976 #[test]
977 fn contingency_type_from_str() {
978 assert_eq!(
979 "SVCAN".parse::<ContingencyType>().unwrap(),
980 ContingencyType::SvcAn
981 );
982 assert_eq!(
983 "SVC-AN".parse::<ContingencyType>().unwrap(),
984 ContingencyType::SvcAn
985 );
986 assert_eq!(
987 "SVCRS".parse::<ContingencyType>().unwrap(),
988 ContingencyType::SvcRs
989 );
990 assert_eq!(
991 "EPEC".parse::<ContingencyType>().unwrap(),
992 ContingencyType::Epec
993 );
994 assert_eq!(
995 "FSDA".parse::<ContingencyType>().unwrap(),
996 ContingencyType::FsDa
997 );
998 assert_eq!(
999 "FSIA".parse::<ContingencyType>().unwrap(),
1000 ContingencyType::FsIa
1001 );
1002 assert_eq!(
1003 "OFFLINE".parse::<ContingencyType>().unwrap(),
1004 ContingencyType::Offline
1005 );
1006 assert!("UNKNOWN".parse::<ContingencyType>().is_err());
1007 }
1008
1009 #[test]
1010 fn contingency_type_from_tp_emis() {
1011 assert_eq!(
1012 ContingencyType::from_tp_emis(2),
1013 Some(ContingencyType::FsIa)
1014 );
1015 assert_eq!(
1016 ContingencyType::from_tp_emis(4),
1017 Some(ContingencyType::Epec)
1018 );
1019 assert_eq!(
1020 ContingencyType::from_tp_emis(5),
1021 Some(ContingencyType::FsDa)
1022 );
1023 assert_eq!(
1024 ContingencyType::from_tp_emis(6),
1025 Some(ContingencyType::SvcAn)
1026 );
1027 assert_eq!(
1028 ContingencyType::from_tp_emis(7),
1029 Some(ContingencyType::SvcRs)
1030 );
1031 assert_eq!(
1032 ContingencyType::from_tp_emis(9),
1033 Some(ContingencyType::Offline)
1034 );
1035 assert_eq!(ContingencyType::from_tp_emis(1), None);
1036 assert_eq!(ContingencyType::from_tp_emis(0), None);
1037 assert_eq!(ContingencyType::from_tp_emis(3), None);
1038 }
1039
1040 #[test]
1041 fn escape_json_string_basic() {
1042 assert_eq!(escape_json_string("hello"), "hello");
1043 assert_eq!(escape_json_string(r#"say "hi""#), r#"say \"hi\""#);
1044 assert_eq!(escape_json_string("a\\b"), "a\\\\b");
1045 }
1046
1047 #[test]
1048 #[should_panic(expected = "Unknown state abbreviation")]
1049 fn contingency_for_state_unknown_panics() {
1050 contingency_for_state("XX");
1051 }
1052
1053 #[test]
1054 fn display_for_contingency_matches_to_json() {
1055 let c = Contingency::new();
1056 assert_eq!(c.to_string(), c.to_json());
1057 }
1058
1059 #[test]
1062 fn adjust_nfe_contingency_inactive_returns_unchanged() {
1063 let c = Contingency::new();
1064 let xml = "<NFe><infNFe/></NFe>";
1065 let result = adjust_nfe_contingency(xml, &c).unwrap();
1066 assert_eq!(result, xml);
1067 }
1068
1069 #[test]
1070 fn adjust_nfe_contingency_model65_returns_error() {
1071 let mut c = Contingency::new();
1072 c.activate(
1073 ContingencyType::SvcAn,
1074 "Motivo de contingencia teste valido",
1075 )
1076 .unwrap();
1077 let xml = "<NFe><infNFe><ide><mod>65</mod><tpEmis>1</tpEmis></ide></infNFe></NFe>";
1078 let err = adjust_nfe_contingency(xml, &c).unwrap_err();
1079 assert!(matches!(err, FiscalError::Contingency(_)));
1080 }
1081
1082 #[test]
1083 fn adjust_nfe_contingency_already_non_normal_returns_unchanged() {
1084 let mut c = Contingency::new();
1085 c.activate(
1086 ContingencyType::SvcAn,
1087 "Motivo de contingencia teste valido",
1088 )
1089 .unwrap();
1090 let xml = "<NFe><infNFe><ide><mod>55</mod><tpEmis>6</tpEmis></ide></infNFe></NFe>";
1091 let result = adjust_nfe_contingency(xml, &c).unwrap();
1092 assert!(result.contains("<tpEmis>6</tpEmis>"));
1093 }
1094
1095 #[test]
1096 fn adjust_nfe_contingency_replaces_tp_emis_and_inserts_dh_cont() {
1097 let mut c = Contingency::new();
1098 c.activate(
1099 ContingencyType::SvcAn,
1100 "Motivo de contingencia teste valido",
1101 )
1102 .unwrap();
1103 let xml = concat!(
1104 r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1105 "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1106 "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1107 "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1108 "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1109 "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1110 "<tpAmb>2</tpAmb></ide>",
1111 "<emit><CNPJ>04123456000190</CNPJ></emit>",
1112 "</infNFe></NFe>"
1113 );
1114 let result = adjust_nfe_contingency(xml, &c).unwrap();
1115 assert!(result.contains("<tpEmis>6</tpEmis>"));
1116 assert!(result.contains("<dhCont>"));
1117 assert!(result.contains("<xJust>"));
1118 }
1119
1120 #[test]
1121 fn adjust_nfe_contingency_replaces_existing_dh_cont() {
1122 let mut c = Contingency::new();
1123 c.activate(
1124 ContingencyType::SvcAn,
1125 "Motivo de contingencia teste valido",
1126 )
1127 .unwrap();
1128 let xml = concat!(
1129 r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1130 "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1131 "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1132 "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1133 "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1134 "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1135 "<tpAmb>2</tpAmb>",
1136 "<dhCont>2020-01-01T00:00:00-03:00</dhCont>",
1137 "<xJust>old reason</xJust>",
1138 "</ide>",
1139 "<emit><CNPJ>04123456000190</CNPJ></emit>",
1140 "</infNFe></NFe>"
1141 );
1142 let result = adjust_nfe_contingency(xml, &c).unwrap();
1143 assert!(result.contains("<tpEmis>6</tpEmis>"));
1144 assert!(!result.contains("old reason"));
1145 assert!(result.contains("Motivo de contingencia teste valido"));
1146 }
1147
1148 #[test]
1149 fn adjust_nfe_contingency_inserts_before_nfref() {
1150 let mut c = Contingency::new();
1151 c.activate(
1152 ContingencyType::SvcRs,
1153 "Motivo de contingencia teste valido para NFRef",
1154 )
1155 .unwrap();
1156 let xml = concat!(
1157 r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1158 "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1159 "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1160 "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1161 "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1162 "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1163 "<tpAmb>2</tpAmb><NFref><refNFe>123</refNFe></NFref></ide>",
1164 "<emit><CNPJ>04123456000190</CNPJ></emit>",
1165 "</infNFe></NFe>"
1166 );
1167 let result = adjust_nfe_contingency(xml, &c).unwrap();
1168 assert!(result.contains("<tpEmis>7</tpEmis>"));
1169 let dh_pos = result.find("<dhCont>").unwrap();
1171 let nfref_pos = result.find("<NFref>").unwrap();
1172 assert!(dh_pos < nfref_pos);
1173 }
1174
1175 #[test]
1176 fn adjust_nfe_contingency_removes_signature() {
1177 let mut c = Contingency::new();
1178 c.activate(
1179 ContingencyType::SvcAn,
1180 "Motivo de contingencia teste valido",
1181 )
1182 .unwrap();
1183 let xml = concat!(
1184 r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1185 "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1186 "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1187 "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1188 "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1189 "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1190 "<tpAmb>2</tpAmb></ide>",
1191 "<emit><CNPJ>04123456000190</CNPJ></emit>",
1192 "</infNFe>",
1193 r#"<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo/></Signature>"#,
1194 "</NFe>"
1195 );
1196 let result = adjust_nfe_contingency(xml, &c).unwrap();
1197 assert!(!result.contains("<Signature"));
1198 }
1199
1200 #[test]
1201 fn extract_emitter_doc_cpf() {
1202 let xml = "<root><emit><CPF>12345678901</CPF></emit></root>";
1203 assert_eq!(extract_emitter_doc(xml), "12345678901");
1204 }
1205
1206 #[test]
1207 fn extract_emitter_doc_no_emit() {
1208 let xml = "<root><other/></root>";
1209 assert_eq!(extract_emitter_doc(xml), "");
1210 }
1211
1212 #[test]
1213 fn parse_year_month_short_input() {
1214 let (y, m) = parse_year_month("2026");
1215 assert_eq!(y, "00");
1216 assert_eq!(m, "00");
1217 }
1218
1219 #[test]
1220 fn extract_tz_offset_no_offset() {
1221 assert_eq!(extract_tz_offset("2026"), "-03:00");
1222 }
1223
1224 #[test]
1225 fn format_timestamp_with_offset_bad_offset() {
1226 let result = format_timestamp_with_offset(0, "X");
1228 assert!(result.contains("1970"));
1229 }
1230
1231 #[test]
1232 fn escape_json_string_control_chars() {
1233 let s = escape_json_string("a\nb\tc\rd");
1234 assert_eq!(s, "a\\nb\\tc\\rd");
1235 }
1236
1237 #[test]
1238 fn all_27_states_have_mapping() {
1239 let states = [
1240 "AC", "AL", "AM", "AP", "BA", "CE", "DF", "ES", "GO", "MA", "MG", "MS", "MT", "PA",
1241 "PB", "PE", "PI", "PR", "RJ", "RN", "RO", "RR", "RS", "SC", "SE", "SP", "TO",
1242 ];
1243 for uf in states {
1244 let ct = contingency_for_state(uf);
1245 assert!(
1246 ct == ContingencyType::SvcAn || ct == ContingencyType::SvcRs,
1247 "State {uf} should map to SVC-AN or SVC-RS"
1248 );
1249 }
1250 }
1251}