fiscal_core/contingency/
manager.rs1use crate::FiscalError;
2use crate::types::{ContingencyType, EmissionType, InvoiceModel};
3
4#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "camelCase")]
22#[non_exhaustive]
23pub struct Contingency {
24 pub contingency_type: Option<ContingencyType>,
26 pub reason: Option<String>,
28 pub activated_at: Option<String>,
30 pub timestamp: u64,
32}
33
34impl Contingency {
35 pub fn new() -> Self {
37 Self {
38 contingency_type: None,
39 reason: None,
40 activated_at: None,
41 timestamp: 0,
42 }
43 }
44
45 pub fn is_active(&self) -> bool {
47 self.contingency_type.is_some()
48 }
49
50 pub fn activate(
61 &mut self,
62 contingency_type: ContingencyType,
63 reason: &str,
64 ) -> Result<(), FiscalError> {
65 let trimmed = reason.trim();
66 let len = trimmed.chars().count();
67 if !(15..=255).contains(&len) {
68 return Err(FiscalError::Contingency(
69 "The justification for entering contingency mode must be between 15 and 255 UTF-8 characters.".to_string(),
70 ));
71 }
72
73 let now = std::time::SystemTime::now()
75 .duration_since(std::time::UNIX_EPOCH)
76 .unwrap_or_default()
77 .as_secs();
78
79 self.contingency_type = Some(contingency_type);
80 self.reason = Some(trimmed.to_string());
81 self.timestamp = now;
82 self.activated_at = Some(
83 chrono::DateTime::from_timestamp(now as i64, 0)
84 .unwrap_or_default()
85 .to_rfc3339(),
86 );
87 Ok(())
88 }
89
90 pub fn deactivate(&mut self) {
92 self.contingency_type = None;
93 self.reason = None;
94 self.activated_at = None;
95 self.timestamp = 0;
96 }
97
98 pub fn load(json: &str) -> Result<Self, FiscalError> {
114 let motive = extract_json_string(json, "motive")
116 .ok_or_else(|| FiscalError::Contingency("Missing 'motive' in JSON".to_string()))?;
117 let timestamp = extract_json_number(json, "timestamp")
118 .ok_or_else(|| FiscalError::Contingency("Missing 'timestamp' in JSON".to_string()))?;
119 let type_str = extract_json_string(json, "type")
120 .ok_or_else(|| FiscalError::Contingency("Missing 'type' in JSON".to_string()))?;
121 let tp_emis = extract_json_number(json, "tpEmis")
122 .ok_or_else(|| FiscalError::Contingency("Missing 'tpEmis' in JSON".to_string()))?;
123
124 let contingency_type = ContingencyType::from_type_str(&type_str);
125
126 if !type_str.is_empty() && contingency_type.is_none() {
128 return Err(FiscalError::Contingency(format!(
129 "Unrecognized contingency type: {type_str}"
130 )));
131 }
132
133 let _ = tp_emis; Ok(Self {
136 contingency_type,
137 reason: if motive.is_empty() {
138 None
139 } else {
140 Some(motive)
141 },
142 activated_at: if timestamp > 0 {
143 Some(
144 chrono::DateTime::from_timestamp(timestamp as i64, 0)
145 .unwrap_or_default()
146 .to_rfc3339(),
147 )
148 } else {
149 None
150 },
151 timestamp,
152 })
153 }
154
155 pub fn to_json(&self) -> String {
167 let motive = self.reason.as_deref().unwrap_or("");
168 let type_str = self
169 .contingency_type
170 .map(|ct| ct.to_type_str())
171 .unwrap_or("");
172 let tp_emis = self.emission_type();
173 format!(
174 r#"{{"motive":"{}","timestamp":{},"type":"{}","tpEmis":{}}}"#,
175 escape_json_string(motive),
176 self.timestamp,
177 type_str,
178 tp_emis
179 )
180 }
181
182 pub fn emission_type(&self) -> u8 {
188 match self.contingency_type {
189 Some(ct) => ct.tp_emis(),
190 None => 1,
191 }
192 }
193
194 pub fn emission_type_enum(&self) -> EmissionType {
196 match self.contingency_type {
197 Some(ContingencyType::SvcAn) => EmissionType::SvcAn,
198 Some(ContingencyType::SvcRs) => EmissionType::SvcRs,
199 Some(ContingencyType::Epec) => EmissionType::Epec,
200 Some(ContingencyType::FsDa) => EmissionType::FsDa,
201 Some(ContingencyType::FsIa) => EmissionType::FsIa,
202 Some(ContingencyType::Offline) => EmissionType::Offline,
203 None => EmissionType::Normal,
204 }
205 }
206
207 pub fn check_web_service_availability(&self, model: InvoiceModel) -> Result<(), FiscalError> {
221 let ct = match self.contingency_type {
222 Some(ct) => ct,
223 None => return Ok(()),
224 };
225
226 if model == InvoiceModel::Nfce
227 && matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs)
228 {
229 return Err(FiscalError::Contingency(
230 "Não existe serviço para contingência SVCRS ou SVCAN para NFCe (modelo 65)."
231 .to_string(),
232 ));
233 }
234
235 if !matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs) {
236 return Err(FiscalError::Contingency(format!(
237 "Esse modo de contingência [{}] não possui webservice próprio, portanto não haverão envios.",
238 ct.to_type_str()
239 )));
240 }
241
242 Ok(())
243 }
244}
245
246impl Default for Contingency {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252impl core::fmt::Display for Contingency {
253 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
254 f.write_str(&self.to_json())
255 }
256}
257
258pub fn contingency_for_state(uf: &str) -> ContingencyType {
269 match uf {
270 "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => ContingencyType::SvcRs,
271 "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
272 | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => ContingencyType::SvcAn,
273 _ => panic!("Unknown state abbreviation: {uf}"),
274 }
275}
276
277pub fn try_contingency_for_state(uf: &str) -> Result<ContingencyType, FiscalError> {
286 match uf {
287 "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => Ok(ContingencyType::SvcRs),
288 "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
289 | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => Ok(ContingencyType::SvcAn),
290 _ => Err(FiscalError::InvalidStateCode(uf.to_string())),
291 }
292}
293
294pub(super) fn escape_json_string(s: &str) -> String {
298 let mut out = String::with_capacity(s.len());
299 for c in s.chars() {
300 match c {
301 '"' => out.push_str("\\\""),
302 '\\' => out.push_str("\\\\"),
303 '\n' => out.push_str("\\n"),
304 '\r' => out.push_str("\\r"),
305 '\t' => out.push_str("\\t"),
306 c if c.is_control() => {
307 for unit in c.encode_utf16(&mut [0; 2]) {
309 out.push_str(&format!("\\u{unit:04x}"));
310 }
311 }
312 _ => out.push(c),
313 }
314 }
315 out
316}
317
318pub(super) fn extract_json_string(json: &str, key: &str) -> Option<String> {
321 let search = format!("\"{key}\"");
322 let idx = json.find(&search)?;
323 let after_key = idx + search.len();
324 let rest = json[after_key..].trim_start();
326 let rest = rest.strip_prefix(':')?;
327 let rest = rest.trim_start();
328
329 if let Some(content) = rest.strip_prefix('"') {
330 let end = content.find('"')?;
332 Some(content[..end].to_string())
333 } else {
334 None
335 }
336}
337
338pub(super) fn extract_json_number(json: &str, key: &str) -> Option<u64> {
341 let search = format!("\"{key}\"");
342 let idx = json.find(&search)?;
343 let after_key = idx + search.len();
344 let rest = json[after_key..].trim_start();
345 let rest = rest.strip_prefix(':')?;
346 let rest = rest.trim_start();
347
348 let end = rest
350 .find(|c: char| !c.is_ascii_digit())
351 .unwrap_or(rest.len());
352 if end == 0 {
353 return None;
354 }
355 rest[..end].parse().ok()
356}