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..=256).contains(&len) {
70 return Err(FiscalError::Contingency(
71 "The justification for entering contingency mode must be between 15 and 256 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(257);
699 let result = c.activate(ContingencyType::SvcAn, &motive);
700 assert!(result.is_err());
701 }
702
703 #[test]
704 fn deactivate_clears_state() {
705 let mut c = Contingency::new();
706 c.activate(
707 ContingencyType::SvcAn,
708 "A valid reason for contingency mode activation",
709 )
710 .unwrap();
711 c.deactivate();
712 assert!(c.contingency_type.is_none());
713 assert!(!c.is_active());
714 assert_eq!(c.emission_type(), 1);
715 assert_eq!(c.emission_type_enum(), EmissionType::Normal);
716 }
717
718 #[test]
719 fn load_from_json() {
720 let json =
721 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
722 let c = Contingency::load(json).unwrap();
723 assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
724 assert_eq!(c.emission_type(), 6);
725 assert_eq!(c.reason.as_deref(), Some("Testes Unitarios"));
726 assert!(c.is_active());
727 }
728
729 #[test]
730 fn load_svc_rs_from_json() {
731 let json =
732 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
733 let c = Contingency::load(json).unwrap();
734 assert_eq!(c.contingency_type, Some(ContingencyType::SvcRs));
735 assert_eq!(c.emission_type(), 7);
736 }
737
738 #[test]
739 fn load_epec_from_json() {
740 let json =
741 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"EPEC","tpEmis":4}"#;
742 let c = Contingency::load(json).unwrap();
743 assert_eq!(c.contingency_type, Some(ContingencyType::Epec));
744 assert_eq!(c.emission_type(), 4);
745 }
746
747 #[test]
748 fn load_fs_da_from_json() {
749 let json =
750 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSDA","tpEmis":5}"#;
751 let c = Contingency::load(json).unwrap();
752 assert_eq!(c.contingency_type, Some(ContingencyType::FsDa));
753 assert_eq!(c.emission_type(), 5);
754 }
755
756 #[test]
757 fn load_fs_ia_from_json() {
758 let json =
759 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSIA","tpEmis":2}"#;
760 let c = Contingency::load(json).unwrap();
761 assert_eq!(c.contingency_type, Some(ContingencyType::FsIa));
762 assert_eq!(c.emission_type(), 2);
763 }
764
765 #[test]
766 fn load_offline_from_json() {
767 let json =
768 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"OFFLINE","tpEmis":9}"#;
769 let c = Contingency::load(json).unwrap();
770 assert_eq!(c.contingency_type, Some(ContingencyType::Offline));
771 assert_eq!(c.emission_type(), 9);
772 }
773
774 #[test]
775 fn load_deactivated_from_json() {
776 let json = r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#;
777 let c = Contingency::load(json).unwrap();
778 assert!(c.contingency_type.is_none());
779 assert!(!c.is_active());
780 assert_eq!(c.emission_type(), 1);
781 }
782
783 #[test]
784 fn load_rejects_unknown_type() {
785 let json =
786 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"UNKNOWN","tpEmis":1}"#;
787 let result = Contingency::load(json);
788 assert!(result.is_err());
789 }
790
791 #[test]
792 fn to_json_activated() {
793 let json =
794 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
795 let c = Contingency::load(json).unwrap();
796 assert_eq!(c.to_json(), json);
797 }
798
799 #[test]
800 fn to_json_deactivated() {
801 let c = Contingency::new();
802 assert_eq!(
803 c.to_json(),
804 r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
805 );
806 }
807
808 #[test]
809 fn to_json_roundtrip() {
810 let json =
811 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
812 let c = Contingency::load(json).unwrap();
813 let output = c.to_json();
814 assert_eq!(output, json);
815 let c2 = Contingency::load(&output).unwrap();
817 assert_eq!(c2.contingency_type, c.contingency_type);
818 assert_eq!(c2.reason, c.reason);
819 assert_eq!(c2.timestamp, c.timestamp);
820 }
821
822 #[test]
823 fn deactivate_produces_correct_json() {
824 let json =
825 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
826 let mut c = Contingency::load(json).unwrap();
827 c.deactivate();
828 assert_eq!(
829 c.to_json(),
830 r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
831 );
832 }
833
834 #[test]
835 fn display_matches_to_json() {
836 let json =
837 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
838 let c = Contingency::load(json).unwrap();
839 assert_eq!(format!("{c}"), c.to_json());
840 }
841
842 #[test]
843 fn extract_json_string_works() {
844 let json = r#"{"motive":"hello world","type":"SVCAN"}"#;
845 assert_eq!(
846 extract_json_string(json, "motive"),
847 Some("hello world".to_string())
848 );
849 assert_eq!(extract_json_string(json, "type"), Some("SVCAN".to_string()));
850 }
851
852 #[test]
853 fn extract_json_number_works() {
854 let json = r#"{"timestamp":1480700623,"tpEmis":6}"#;
855 assert_eq!(extract_json_number(json, "timestamp"), Some(1480700623));
856 assert_eq!(extract_json_number(json, "tpEmis"), Some(6));
857 }
858
859 #[test]
860 fn format_timestamp_with_offset_formats_correctly() {
861 let result = format_timestamp_with_offset(1480700623, "-03:00");
863 assert_eq!(result, "2016-12-02T14:43:43-03:00");
864 }
865
866 #[test]
867 fn contingency_for_state_sp() {
868 assert_eq!(contingency_for_state("SP").as_str(), "svc-an");
869 }
870
871 #[test]
872 fn contingency_for_state_am() {
873 assert_eq!(contingency_for_state("AM").as_str(), "svc-rs");
874 }
875
876 #[test]
877 fn try_contingency_for_state_valid() {
878 assert_eq!(
879 try_contingency_for_state("SP").unwrap(),
880 ContingencyType::SvcAn
881 );
882 assert_eq!(
883 try_contingency_for_state("AM").unwrap(),
884 ContingencyType::SvcRs
885 );
886 }
887
888 #[test]
889 fn try_contingency_for_state_invalid() {
890 assert!(try_contingency_for_state("XX").is_err());
891 }
892
893 #[test]
894 fn check_web_service_nfe_svc_an_ok() {
895 let mut c = Contingency::new();
896 c.activate(
897 ContingencyType::SvcAn,
898 "A valid reason for contingency mode activation",
899 )
900 .unwrap();
901 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
902 }
903
904 #[test]
905 fn check_web_service_nfe_svc_rs_ok() {
906 let mut c = Contingency::new();
907 c.activate(
908 ContingencyType::SvcRs,
909 "A valid reason for contingency mode activation",
910 )
911 .unwrap();
912 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
913 }
914
915 #[test]
916 fn check_web_service_nfce_svc_fails() {
917 let mut c = Contingency::new();
918 c.activate(
919 ContingencyType::SvcAn,
920 "A valid reason for contingency mode activation",
921 )
922 .unwrap();
923 assert!(
924 c.check_web_service_availability(InvoiceModel::Nfce)
925 .is_err()
926 );
927 }
928
929 #[test]
930 fn check_web_service_epec_no_webservice() {
931 let mut c = Contingency::new();
932 c.activate(
933 ContingencyType::Epec,
934 "A valid reason for contingency mode activation",
935 )
936 .unwrap();
937 let err = c
938 .check_web_service_availability(InvoiceModel::Nfe)
939 .unwrap_err();
940 assert!(err.to_string().contains("EPEC"));
941 }
942
943 #[test]
944 fn check_web_service_normal_mode_ok() {
945 let c = Contingency::new();
946 assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
947 assert!(c.check_web_service_availability(InvoiceModel::Nfce).is_ok());
948 }
949
950 #[test]
951 fn contingency_type_display() {
952 assert_eq!(format!("{}", ContingencyType::SvcAn), "SVCAN");
953 assert_eq!(format!("{}", ContingencyType::SvcRs), "SVCRS");
954 assert_eq!(format!("{}", ContingencyType::Epec), "EPEC");
955 assert_eq!(format!("{}", ContingencyType::FsDa), "FSDA");
956 assert_eq!(format!("{}", ContingencyType::FsIa), "FSIA");
957 assert_eq!(format!("{}", ContingencyType::Offline), "OFFLINE");
958 }
959
960 #[test]
961 fn contingency_type_from_str() {
962 assert_eq!(
963 "SVCAN".parse::<ContingencyType>().unwrap(),
964 ContingencyType::SvcAn
965 );
966 assert_eq!(
967 "SVC-AN".parse::<ContingencyType>().unwrap(),
968 ContingencyType::SvcAn
969 );
970 assert_eq!(
971 "SVCRS".parse::<ContingencyType>().unwrap(),
972 ContingencyType::SvcRs
973 );
974 assert_eq!(
975 "EPEC".parse::<ContingencyType>().unwrap(),
976 ContingencyType::Epec
977 );
978 assert_eq!(
979 "FSDA".parse::<ContingencyType>().unwrap(),
980 ContingencyType::FsDa
981 );
982 assert_eq!(
983 "FSIA".parse::<ContingencyType>().unwrap(),
984 ContingencyType::FsIa
985 );
986 assert_eq!(
987 "OFFLINE".parse::<ContingencyType>().unwrap(),
988 ContingencyType::Offline
989 );
990 assert!("UNKNOWN".parse::<ContingencyType>().is_err());
991 }
992
993 #[test]
994 fn contingency_type_from_tp_emis() {
995 assert_eq!(
996 ContingencyType::from_tp_emis(2),
997 Some(ContingencyType::FsIa)
998 );
999 assert_eq!(
1000 ContingencyType::from_tp_emis(4),
1001 Some(ContingencyType::Epec)
1002 );
1003 assert_eq!(
1004 ContingencyType::from_tp_emis(5),
1005 Some(ContingencyType::FsDa)
1006 );
1007 assert_eq!(
1008 ContingencyType::from_tp_emis(6),
1009 Some(ContingencyType::SvcAn)
1010 );
1011 assert_eq!(
1012 ContingencyType::from_tp_emis(7),
1013 Some(ContingencyType::SvcRs)
1014 );
1015 assert_eq!(
1016 ContingencyType::from_tp_emis(9),
1017 Some(ContingencyType::Offline)
1018 );
1019 assert_eq!(ContingencyType::from_tp_emis(1), None);
1020 assert_eq!(ContingencyType::from_tp_emis(0), None);
1021 assert_eq!(ContingencyType::from_tp_emis(3), None);
1022 }
1023
1024 #[test]
1025 fn escape_json_string_basic() {
1026 assert_eq!(escape_json_string("hello"), "hello");
1027 assert_eq!(escape_json_string(r#"say "hi""#), r#"say \"hi\""#);
1028 assert_eq!(escape_json_string("a\\b"), "a\\\\b");
1029 }
1030
1031 #[test]
1032 fn all_27_states_have_mapping() {
1033 let states = [
1034 "AC", "AL", "AM", "AP", "BA", "CE", "DF", "ES", "GO", "MA", "MG", "MS", "MT", "PA",
1035 "PB", "PE", "PI", "PR", "RJ", "RN", "RO", "RR", "RS", "SC", "SE", "SP", "TO",
1036 ];
1037 for uf in states {
1038 let ct = contingency_for_state(uf);
1039 assert!(
1040 ct == ContingencyType::SvcAn || ct == ContingencyType::SvcRs,
1041 "State {uf} should map to SVC-AN or SVC-RS"
1042 );
1043 }
1044 }
1045}