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)]
13#[non_exhaustive]
14pub struct Contingency {
15 pub contingency_type: Option<ContingencyType>,
17 pub reason: Option<String>,
19 pub activated_at: Option<String>,
21 pub timestamp: u64,
23}
24
25impl Contingency {
26 pub fn new() -> Self {
28 Self {
29 contingency_type: None,
30 reason: None,
31 activated_at: None,
32 timestamp: 0,
33 }
34 }
35
36 pub fn activate(
47 &mut self,
48 contingency_type: ContingencyType,
49 reason: &str,
50 ) -> Result<(), FiscalError> {
51 let trimmed = reason.trim();
52 let len = trimmed.chars().count();
53 if !(15..=256).contains(&len) {
54 return Err(FiscalError::Contingency(
55 "The justification for entering contingency mode must be between 15 and 256 UTF-8 characters.".to_string(),
56 ));
57 }
58
59 let now = std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_secs();
64
65 self.contingency_type = Some(contingency_type);
66 self.reason = Some(trimmed.to_string());
67 self.timestamp = now;
68 self.activated_at = Some(
69 chrono::DateTime::from_timestamp(now as i64, 0)
70 .unwrap_or_default()
71 .to_rfc3339(),
72 );
73 Ok(())
74 }
75
76 pub fn deactivate(&mut self) {
78 self.contingency_type = None;
79 self.reason = None;
80 self.activated_at = None;
81 self.timestamp = 0;
82 }
83
84 pub fn load(json: &str) -> Result<Self, FiscalError> {
96 let motive = extract_json_string(json, "motive")
98 .ok_or_else(|| FiscalError::Contingency("Missing 'motive' in JSON".to_string()))?;
99 let timestamp = extract_json_number(json, "timestamp")
100 .ok_or_else(|| FiscalError::Contingency("Missing 'timestamp' in JSON".to_string()))?;
101 let type_str = extract_json_string(json, "type")
102 .ok_or_else(|| FiscalError::Contingency("Missing 'type' in JSON".to_string()))?;
103 let tp_emis = extract_json_number(json, "tpEmis")
104 .ok_or_else(|| FiscalError::Contingency("Missing 'tpEmis' in JSON".to_string()))?;
105
106 let contingency_type = match type_str.as_str() {
107 "SVCAN" | "SVC-AN" | "svc-an" => Some(ContingencyType::SvcAn),
108 "SVCRS" | "SVC-RS" | "svc-rs" => Some(ContingencyType::SvcRs),
109 "offline" | "OFFLINE" => Some(ContingencyType::Offline),
110 "" => None,
111 other => {
112 return Err(FiscalError::Contingency(format!(
113 "Unrecognized contingency type: {other}"
114 )));
115 }
116 };
117
118 let _ = tp_emis; Ok(Self {
121 contingency_type,
122 reason: if motive.is_empty() {
123 None
124 } else {
125 Some(motive)
126 },
127 activated_at: if timestamp > 0 {
128 Some(
129 chrono::DateTime::from_timestamp(timestamp as i64, 0)
130 .unwrap_or_default()
131 .to_rfc3339(),
132 )
133 } else {
134 None
135 },
136 timestamp,
137 })
138 }
139
140 pub fn emission_type(&self) -> u8 {
145 match self.contingency_type {
146 Some(ContingencyType::SvcAn) => 6,
147 Some(ContingencyType::SvcRs) => 7,
148 Some(ContingencyType::Offline) => 9,
149 None => 1,
150 }
151 }
152
153 pub fn emission_type_enum(&self) -> EmissionType {
155 match self.contingency_type {
156 Some(ContingencyType::SvcAn) => EmissionType::SvcAn,
157 Some(ContingencyType::SvcRs) => EmissionType::SvcRs,
158 Some(ContingencyType::Offline) => EmissionType::Offline,
159 None => EmissionType::Normal,
160 }
161 }
162}
163
164impl Default for Contingency {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170pub fn contingency_for_state(uf: &str) -> ContingencyType {
181 match uf {
182 "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => ContingencyType::SvcRs,
183 "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
184 | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => ContingencyType::SvcAn,
185 _ => panic!("Unknown state abbreviation: {uf}"),
186 }
187}
188
189pub fn adjust_nfe_contingency(xml: &str, contingency: &Contingency) -> Result<String, FiscalError> {
206 if contingency.contingency_type.is_none() {
208 return Ok(xml.to_string());
209 }
210
211 let mut xml = remove_signature(xml);
213
214 let model = extract_xml_tag_value(&xml, "mod").unwrap_or_default();
216 if model == "65" {
217 return Err(FiscalError::Contingency(
218 "The XML belongs to a model 65 document (NFC-e), incorrect for SVCAN or SVCRS contingency.".to_string(),
219 ));
220 }
221
222 let current_tp_emis = extract_xml_tag_value(&xml, "tpEmis").unwrap_or_default();
224 if current_tp_emis != "1" {
225 return Ok(xml);
227 }
228
229 let c_uf = extract_xml_tag_value(&xml, "cUF").unwrap_or_default();
231 let c_nf = extract_xml_tag_value(&xml, "cNF").unwrap_or_default();
232 let n_nf = extract_xml_tag_value(&xml, "nNF").unwrap_or_default();
233 let serie = extract_xml_tag_value(&xml, "serie").unwrap_or_default();
234 let dh_emi = extract_xml_tag_value(&xml, "dhEmi").unwrap_or_default();
235
236 let emit_doc = extract_emitter_doc(&xml);
238
239 let (year, month) = parse_year_month(&dh_emi);
241
242 let tz_offset = extract_tz_offset(&dh_emi);
244 let dth_cont = format_timestamp_with_offset(contingency.timestamp, &tz_offset);
245
246 let reason = contingency.reason.as_deref().unwrap_or("").trim();
247 let tp_emis = contingency.emission_type();
248
249 xml = xml.replacen(
251 &format!("<tpEmis>{current_tp_emis}</tpEmis>"),
252 &format!("<tpEmis>{tp_emis}</tpEmis>"),
253 1,
254 );
255
256 if xml.contains("<dhCont>") {
258 let re_start = xml.find("<dhCont>").unwrap();
260 let re_end = xml.find("</dhCont>").unwrap() + "</dhCont>".len();
261 xml = format!(
262 "{}<dhCont>{dth_cont}</dhCont>{}",
263 &xml[..re_start],
264 &xml[re_end..]
265 );
266 } else if xml.contains("<NFref>") {
267 xml = xml.replacen("<NFref>", &format!("<dhCont>{dth_cont}</dhCont><NFref>"), 1);
268 } else {
269 xml = xml.replacen("</ide>", &format!("<dhCont>{dth_cont}</dhCont></ide>"), 1);
270 }
271
272 if xml.contains("<xJust>") {
274 let re_start = xml.find("<xJust>").unwrap();
276 let re_end = xml.find("</xJust>").unwrap() + "</xJust>".len();
277 xml = format!(
278 "{}<xJust>{reason}</xJust>{}",
279 &xml[..re_start],
280 &xml[re_end..]
281 );
282 } else if xml.contains("<NFref>") {
283 xml = xml.replacen("<NFref>", &format!("<xJust>{reason}</xJust><NFref>"), 1);
284 } else {
285 xml = xml.replacen("</ide>", &format!("<xJust>{reason}</xJust></ide>"), 1);
286 }
287
288 let model_enum = match model.as_str() {
290 "65" => InvoiceModel::Nfce,
291 _ => InvoiceModel::Nfe,
292 };
293 let emission_type_enum = contingency.emission_type_enum();
294
295 let new_key = build_access_key(&AccessKeyParams {
296 state_code: IbgeCode(c_uf),
297 year_month: format!("{year}{month}"),
298 tax_id: emit_doc,
299 model: model_enum,
300 series: serie.parse().unwrap_or(0),
301 number: n_nf.parse().unwrap_or(0),
302 emission_type: emission_type_enum,
303 numeric_code: c_nf,
304 })?;
305
306 let new_cdv = &new_key[new_key.len() - 1..];
308 if let Some(start) = xml.find("<cDV>") {
310 if let Some(end) = xml[start..].find("</cDV>") {
311 let full_end = start + end + "</cDV>".len();
312 xml = format!("{}<cDV>{new_cdv}</cDV>{}", &xml[..start], &xml[full_end..]);
313 }
314 }
315
316 if let Some(id_start) = xml.find("Id=\"NFe") {
319 let after_nfe = id_start + 7; if xml.len() >= after_nfe + 44 {
322 let id_end = after_nfe + 44;
323 xml = format!("{}NFe{new_key}{}", &xml[..after_nfe], &xml[id_end..]);
324 }
325 }
326
327 Ok(xml)
328}
329
330fn remove_signature(xml: &str) -> String {
334 if let Some(start) = xml.find("<Signature") {
336 if let Some(end) = xml.find("</Signature>") {
337 let full_end = end + "</Signature>".len();
338 return format!("{}{}", xml[..start].trim_end(), &xml[full_end..])
339 .trim()
340 .to_string();
341 }
342 }
343 xml.to_string()
344}
345
346fn extract_emitter_doc(xml: &str) -> String {
348 if let Some(emit_start) = xml.find("<emit>") {
349 if let Some(emit_end) = xml.find("</emit>") {
350 let emit_block = &xml[emit_start..emit_end];
351 if let Some(cnpj) = extract_inner(emit_block, "CNPJ") {
353 return cnpj;
354 }
355 if let Some(cpf) = extract_inner(emit_block, "CPF") {
357 return cpf;
358 }
359 }
360 }
361 String::new()
362}
363
364fn extract_inner(xml: &str, tag: &str) -> Option<String> {
366 let open = format!("<{tag}>");
367 let close = format!("</{tag}>");
368 let start = xml.find(&open)? + open.len();
369 let end = xml[start..].find(&close)? + start;
370 Some(xml[start..end].to_string())
371}
372
373fn parse_year_month(dh_emi: &str) -> (String, String) {
375 if dh_emi.len() >= 7 {
376 let year = &dh_emi[2..4]; let month = &dh_emi[5..7]; (year.to_string(), month.to_string())
379 } else {
380 ("00".to_string(), "00".to_string())
381 }
382}
383
384fn extract_tz_offset(dh_emi: &str) -> String {
387 if dh_emi.len() >= 6 {
389 let tail = &dh_emi[dh_emi.len() - 6..];
390 if (tail.starts_with('+') || tail.starts_with('-')) && tail.as_bytes()[3] == b':' {
391 return tail.to_string();
392 }
393 }
394 "-03:00".to_string()
395}
396
397fn format_timestamp_with_offset(timestamp: u64, offset: &str) -> String {
399 let offset_seconds = parse_offset_seconds(offset);
401
402 if let Some(fo) = chrono::FixedOffset::east_opt(offset_seconds) {
404 if let Some(dt) = chrono::DateTime::from_timestamp(timestamp as i64, 0) {
405 let local = dt.with_timezone(&fo);
406 return local.format("%Y-%m-%dT%H:%M:%S").to_string() + offset;
407 }
408 }
409
410 format!("1970-01-01T00:00:00{offset}")
412}
413
414fn parse_offset_seconds(offset: &str) -> i32 {
416 if offset.len() < 6 {
417 return 0;
418 }
419 let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
420 let hours: i32 = offset[1..3].parse().unwrap_or(0);
421 let minutes: i32 = offset[4..6].parse().unwrap_or(0);
422 sign * (hours * 3600 + minutes * 60)
423}
424
425fn extract_json_string(json: &str, key: &str) -> Option<String> {
428 let search = format!("\"{key}\"");
429 let idx = json.find(&search)?;
430 let after_key = idx + search.len();
431 let rest = json[after_key..].trim_start();
433 let rest = rest.strip_prefix(':')?;
434 let rest = rest.trim_start();
435
436 if let Some(content) = rest.strip_prefix('"') {
437 let end = content.find('"')?;
439 Some(content[..end].to_string())
440 } else {
441 None
442 }
443}
444
445fn extract_json_number(json: &str, key: &str) -> Option<u64> {
448 let search = format!("\"{key}\"");
449 let idx = json.find(&search)?;
450 let after_key = idx + search.len();
451 let rest = json[after_key..].trim_start();
452 let rest = rest.strip_prefix(':')?;
453 let rest = rest.trim_start();
454
455 let end = rest
457 .find(|c: char| !c.is_ascii_digit())
458 .unwrap_or(rest.len());
459 if end == 0 {
460 return None;
461 }
462 rest[..end].parse().ok()
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn new_contingency_is_inactive() {
471 let c = Contingency::new();
472 assert!(c.contingency_type.is_none());
473 assert_eq!(c.emission_type(), 1);
474 }
475
476 #[test]
477 fn default_is_inactive() {
478 let c = Contingency::default();
479 assert!(c.contingency_type.is_none());
480 }
481
482 #[test]
483 fn activate_sets_fields() {
484 let mut c = Contingency::new();
485 c.activate(
486 ContingencyType::SvcAn,
487 "A valid reason for contingency mode activation",
488 )
489 .unwrap();
490 assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
491 assert_eq!(c.emission_type(), 6);
492 assert!(c.reason.is_some());
493 assert!(c.activated_at.is_some());
494 }
495
496 #[test]
497 fn activate_svc_rs() {
498 let mut c = Contingency::new();
499 c.activate(
500 ContingencyType::SvcRs,
501 "A valid reason for contingency mode activation",
502 )
503 .unwrap();
504 assert_eq!(c.emission_type(), 7);
505 }
506
507 #[test]
508 fn activate_offline() {
509 let mut c = Contingency::new();
510 c.activate(
511 ContingencyType::Offline,
512 "A valid reason for contingency mode activation",
513 )
514 .unwrap();
515 assert_eq!(c.emission_type(), 9);
516 }
517
518 #[test]
519 fn activate_rejects_short_reason() {
520 let mut c = Contingency::new();
521 let result = c.activate(ContingencyType::SvcAn, "Short");
522 assert!(result.is_err());
523 }
524
525 #[test]
526 fn deactivate_clears_state() {
527 let mut c = Contingency::new();
528 c.activate(
529 ContingencyType::SvcAn,
530 "A valid reason for contingency mode activation",
531 )
532 .unwrap();
533 c.deactivate();
534 assert!(c.contingency_type.is_none());
535 assert_eq!(c.emission_type(), 1);
536 }
537
538 #[test]
539 fn load_from_json() {
540 let json =
541 r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
542 let c = Contingency::load(json).unwrap();
543 assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
544 assert_eq!(c.emission_type(), 6);
545 assert_eq!(c.reason.as_deref(), Some("Testes Unitarios"));
546 }
547
548 #[test]
549 fn extract_json_string_works() {
550 let json = r#"{"motive":"hello world","type":"SVCAN"}"#;
551 assert_eq!(
552 extract_json_string(json, "motive"),
553 Some("hello world".to_string())
554 );
555 assert_eq!(extract_json_string(json, "type"), Some("SVCAN".to_string()));
556 }
557
558 #[test]
559 fn extract_json_number_works() {
560 let json = r#"{"timestamp":1480700623,"tpEmis":6}"#;
561 assert_eq!(extract_json_number(json, "timestamp"), Some(1480700623));
562 assert_eq!(extract_json_number(json, "tpEmis"), Some(6));
563 }
564
565 #[test]
566 fn format_timestamp_with_offset_formats_correctly() {
567 let result = format_timestamp_with_offset(1480700623, "-03:00");
569 assert_eq!(result, "2016-12-02T14:43:43-03:00");
570 }
571
572 #[test]
573 fn contingency_for_state_sp() {
574 assert_eq!(contingency_for_state("SP").as_str(), "svc-an");
575 }
576
577 #[test]
578 fn contingency_for_state_am() {
579 assert_eq!(contingency_for_state("AM").as_str(), "svc-rs");
580 }
581}