Skip to main content

fiscal_core/contingency/
manager.rs

1use crate::FiscalError;
2use crate::types::{ContingencyType, EmissionType, InvoiceModel};
3
4/// Contingency manager for NF-e emission.
5///
6/// Manages activation and deactivation of contingency mode, used when the
7/// primary SEFAZ authorizer is unavailable. Supports all contingency types
8/// defined in the NF-e specification: SVC-AN, SVC-RS, EPEC, FS-DA, FS-IA,
9/// and offline.
10///
11/// # JSON persistence
12///
13/// The state can be serialized to / deserialized from a compact JSON string
14/// using [`to_json`](Contingency::to_json) and [`load`](Contingency::load),
15/// matching the PHP `Contingency::__toString()` format:
16///
17/// ```json
18/// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
19/// ```
20#[derive(Debug, Clone)]
21#[non_exhaustive]
22pub struct Contingency {
23    /// The active contingency type, or `None` when in normal mode.
24    pub contingency_type: Option<ContingencyType>,
25    /// Justification reason for entering contingency (15-255 chars).
26    pub reason: Option<String>,
27    /// ISO-8601 timestamp when contingency was activated.
28    pub activated_at: Option<String>,
29    /// Unix timestamp (seconds since epoch) of activation.
30    pub timestamp: u64,
31}
32
33impl Contingency {
34    /// Create a new contingency manager with no active contingency (normal mode).
35    pub fn new() -> Self {
36        Self {
37            contingency_type: None,
38            reason: None,
39            activated_at: None,
40            timestamp: 0,
41        }
42    }
43
44    /// Returns `true` when a contingency mode is currently active.
45    pub fn is_active(&self) -> bool {
46        self.contingency_type.is_some()
47    }
48
49    /// Activate contingency mode with the given type and justification reason.
50    ///
51    /// The reason is trimmed and must be between 15 and 255 UTF-8 characters
52    /// (inclusive). On success, the contingency is activated with the current
53    /// UTC timestamp.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`FiscalError::Contingency`] if the trimmed reason is shorter
58    /// than 15 characters or longer than 255 characters.
59    pub fn activate(
60        &mut self,
61        contingency_type: ContingencyType,
62        reason: &str,
63    ) -> Result<(), FiscalError> {
64        let trimmed = reason.trim();
65        let len = trimmed.chars().count();
66        if !(15..=255).contains(&len) {
67            return Err(FiscalError::Contingency(
68                "The justification for entering contingency mode must be between 15 and 255 UTF-8 characters.".to_string(),
69            ));
70        }
71
72        // Use current UTC timestamp
73        let now = std::time::SystemTime::now()
74            .duration_since(std::time::UNIX_EPOCH)
75            .unwrap_or_default()
76            .as_secs();
77
78        self.contingency_type = Some(contingency_type);
79        self.reason = Some(trimmed.to_string());
80        self.timestamp = now;
81        self.activated_at = Some(
82            chrono::DateTime::from_timestamp(now as i64, 0)
83                .unwrap_or_default()
84                .to_rfc3339(),
85        );
86        Ok(())
87    }
88
89    /// Deactivate contingency mode, resetting to normal emission.
90    pub fn deactivate(&mut self) {
91        self.contingency_type = None;
92        self.reason = None;
93        self.activated_at = None;
94        self.timestamp = 0;
95    }
96
97    /// Load contingency state from a JSON string.
98    ///
99    /// Expected JSON format (matching PHP `Contingency`):
100    /// ```json
101    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
102    /// ```
103    ///
104    /// Accepts all contingency type strings: `SVCAN`, `SVC-AN`, `SVCRS`,
105    /// `SVC-RS`, `EPEC`, `FSDA`, `FS-DA`, `FSIA`, `FS-IA`, `OFFLINE`,
106    /// and their lowercase equivalents.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`FiscalError::Contingency`] if the JSON cannot be parsed or
111    /// contains an unrecognized contingency type.
112    pub fn load(json: &str) -> Result<Self, FiscalError> {
113        // Manual JSON parsing to avoid adding serde_json as a runtime dependency.
114        let motive = extract_json_string(json, "motive")
115            .ok_or_else(|| FiscalError::Contingency("Missing 'motive' in JSON".to_string()))?;
116        let timestamp = extract_json_number(json, "timestamp")
117            .ok_or_else(|| FiscalError::Contingency("Missing 'timestamp' in JSON".to_string()))?;
118        let type_str = extract_json_string(json, "type")
119            .ok_or_else(|| FiscalError::Contingency("Missing 'type' in JSON".to_string()))?;
120        let tp_emis = extract_json_number(json, "tpEmis")
121            .ok_or_else(|| FiscalError::Contingency("Missing 'tpEmis' in JSON".to_string()))?;
122
123        let contingency_type = ContingencyType::from_type_str(&type_str);
124
125        // Validate that, if a type is given, it is recognized
126        if !type_str.is_empty() && contingency_type.is_none() {
127            return Err(FiscalError::Contingency(format!(
128                "Unrecognized contingency type: {type_str}"
129            )));
130        }
131
132        let _ = tp_emis; // Validated through contingency_type mapping
133
134        Ok(Self {
135            contingency_type,
136            reason: if motive.is_empty() {
137                None
138            } else {
139                Some(motive)
140            },
141            activated_at: if timestamp > 0 {
142                Some(
143                    chrono::DateTime::from_timestamp(timestamp as i64, 0)
144                        .unwrap_or_default()
145                        .to_rfc3339(),
146                )
147            } else {
148                None
149            },
150            timestamp,
151        })
152    }
153
154    /// Serialize the contingency state to a JSON string.
155    ///
156    /// Produces the same format as the PHP `Contingency::__toString()`:
157    /// ```json
158    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
159    /// ```
160    ///
161    /// When deactivated, produces:
162    /// ```json
163    /// {"motive":"","timestamp":0,"type":"","tpEmis":1}
164    /// ```
165    pub fn to_json(&self) -> String {
166        let motive = self.reason.as_deref().unwrap_or("");
167        let type_str = self
168            .contingency_type
169            .map(|ct| ct.to_type_str())
170            .unwrap_or("");
171        let tp_emis = self.emission_type();
172        format!(
173            r#"{{"motive":"{}","timestamp":{},"type":"{}","tpEmis":{}}}"#,
174            escape_json_string(motive),
175            self.timestamp,
176            type_str,
177            tp_emis
178        )
179    }
180
181    /// Get the emission type code for the current contingency state.
182    ///
183    /// Returns `1` (normal) if no contingency is active, or the corresponding
184    /// `tpEmis` code: `2` (FS-IA), `4` (EPEC), `5` (FS-DA), `6` (SVC-AN),
185    /// `7` (SVC-RS), `9` (offline).
186    pub fn emission_type(&self) -> u8 {
187        match self.contingency_type {
188            Some(ct) => ct.tp_emis(),
189            None => 1,
190        }
191    }
192
193    /// Get the [`EmissionType`] enum for the current contingency state.
194    pub fn emission_type_enum(&self) -> EmissionType {
195        match self.contingency_type {
196            Some(ContingencyType::SvcAn) => EmissionType::SvcAn,
197            Some(ContingencyType::SvcRs) => EmissionType::SvcRs,
198            Some(ContingencyType::Epec) => EmissionType::Epec,
199            Some(ContingencyType::FsDa) => EmissionType::FsDa,
200            Some(ContingencyType::FsIa) => EmissionType::FsIa,
201            Some(ContingencyType::Offline) => EmissionType::Offline,
202            None => EmissionType::Normal,
203        }
204    }
205
206    /// Check whether the current contingency mode has a dedicated web service.
207    ///
208    /// Only SVC-AN and SVC-RS have their own SEFAZ web services. Other types
209    /// (EPEC, FS-DA, FS-IA, offline) do not have their own web services for
210    /// NF-e authorization and will return an error if used with `sefazAutorizacao`.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`FiscalError::Contingency`] if:
215    /// - The document is model 65 (NFC-e) and an SVC contingency is active
216    ///   (NFC-e does not support SVC-AN/SVC-RS).
217    /// - The active contingency type does not have a dedicated web service
218    ///   (EPEC, FS-DA, FS-IA, offline).
219    pub fn check_web_service_availability(&self, model: InvoiceModel) -> Result<(), FiscalError> {
220        let ct = match self.contingency_type {
221            Some(ct) => ct,
222            None => return Ok(()),
223        };
224
225        if model == InvoiceModel::Nfce
226            && matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs)
227        {
228            return Err(FiscalError::Contingency(
229                "Não existe serviço para contingência SVCRS ou SVCAN para NFCe (modelo 65)."
230                    .to_string(),
231            ));
232        }
233
234        if !matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs) {
235            return Err(FiscalError::Contingency(format!(
236                "Esse modo de contingência [{}] não possui webservice próprio, portanto não haverão envios.",
237                ct.to_type_str()
238            )));
239        }
240
241        Ok(())
242    }
243}
244
245impl Default for Contingency {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl core::fmt::Display for Contingency {
252    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253        f.write_str(&self.to_json())
254    }
255}
256
257/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
258///
259/// Each state has a pre-assigned SVC authorizer:
260/// - **SVC-RS** (8 states): AM, BA, GO, MA, MS, MT, PE, PR
261/// - **SVC-AN** (19 states): all others (AC, AL, AP, CE, DF, ES, MG, PA, PB,
262///   PI, RJ, RN, RO, RR, RS, SC, SE, SP, TO)
263///
264/// # Panics
265///
266/// Panics if `uf` is not a valid 2-letter Brazilian state abbreviation.
267pub fn contingency_for_state(uf: &str) -> ContingencyType {
268    match uf {
269        "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => ContingencyType::SvcRs,
270        "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
271        | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => ContingencyType::SvcAn,
272        _ => panic!("Unknown state abbreviation: {uf}"),
273    }
274}
275
276/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
277///
278/// Same as [`contingency_for_state`] but returns a `Result` instead of panicking.
279///
280/// # Errors
281///
282/// Returns [`FiscalError::InvalidStateCode`] if `uf` is not a valid 2-letter
283/// Brazilian state abbreviation.
284pub fn try_contingency_for_state(uf: &str) -> Result<ContingencyType, FiscalError> {
285    match uf {
286        "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => Ok(ContingencyType::SvcRs),
287        "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
288        | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => Ok(ContingencyType::SvcAn),
289        _ => Err(FiscalError::InvalidStateCode(uf.to_string())),
290    }
291}
292
293// ── Private JSON helpers ────────────────────────────────────────────────────
294
295/// Escape a string for JSON output — handles `"`, `\`, and control characters.
296pub(super) fn escape_json_string(s: &str) -> String {
297    let mut out = String::with_capacity(s.len());
298    for c in s.chars() {
299        match c {
300            '"' => out.push_str("\\\""),
301            '\\' => out.push_str("\\\\"),
302            '\n' => out.push_str("\\n"),
303            '\r' => out.push_str("\\r"),
304            '\t' => out.push_str("\\t"),
305            c if c.is_control() => {
306                // \uXXXX for other control chars
307                for unit in c.encode_utf16(&mut [0; 2]) {
308                    out.push_str(&format!("\\u{unit:04x}"));
309                }
310            }
311            _ => out.push(c),
312        }
313    }
314    out
315}
316
317/// Extract a string value from a simple JSON object by key.
318/// E.g., from `{"key":"value"}` extracts "value" for key "key".
319pub(super) fn extract_json_string(json: &str, key: &str) -> Option<String> {
320    let search = format!("\"{key}\"");
321    let idx = json.find(&search)?;
322    let after_key = idx + search.len();
323    // Skip whitespace and colon
324    let rest = json[after_key..].trim_start();
325    let rest = rest.strip_prefix(':')?;
326    let rest = rest.trim_start();
327
328    if let Some(content) = rest.strip_prefix('"') {
329        // String value
330        let end = content.find('"')?;
331        Some(content[..end].to_string())
332    } else {
333        None
334    }
335}
336
337/// Extract a numeric value from a simple JSON object by key.
338/// E.g., from `{"key":123}` extracts 123 for key "key".
339pub(super) fn extract_json_number(json: &str, key: &str) -> Option<u64> {
340    let search = format!("\"{key}\"");
341    let idx = json.find(&search)?;
342    let after_key = idx + search.len();
343    let rest = json[after_key..].trim_start();
344    let rest = rest.strip_prefix(':')?;
345    let rest = rest.trim_start();
346
347    // Read digits
348    let end = rest
349        .find(|c: char| !c.is_ascii_digit())
350        .unwrap_or(rest.len());
351    if end == 0 {
352        return None;
353    }
354    rest[..end].parse().ok()
355}