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, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "camelCase")]
22#[non_exhaustive]
23pub struct Contingency {
24    /// The active contingency type, or `None` when in normal mode.
25    pub contingency_type: Option<ContingencyType>,
26    /// Justification reason for entering contingency (15-255 chars).
27    pub reason: Option<String>,
28    /// ISO-8601 timestamp when contingency was activated.
29    pub activated_at: Option<String>,
30    /// Unix timestamp (seconds since epoch) of activation.
31    pub timestamp: u64,
32}
33
34impl Contingency {
35    /// Create a new contingency manager with no active contingency (normal mode).
36    pub fn new() -> Self {
37        Self {
38            contingency_type: None,
39            reason: None,
40            activated_at: None,
41            timestamp: 0,
42        }
43    }
44
45    /// Returns `true` when a contingency mode is currently active.
46    pub fn is_active(&self) -> bool {
47        self.contingency_type.is_some()
48    }
49
50    /// Activate contingency mode with the given type and justification reason.
51    ///
52    /// The reason is trimmed and must be between 15 and 255 UTF-8 characters
53    /// (inclusive). On success, the contingency is activated with the current
54    /// UTC timestamp.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`FiscalError::Contingency`] if the trimmed reason is shorter
59    /// than 15 characters or longer than 255 characters.
60    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        // Use current UTC timestamp
74        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    /// Deactivate contingency mode, resetting to normal emission.
91    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    /// Load contingency state from a JSON string.
99    ///
100    /// Expected JSON format (matching PHP `Contingency`):
101    /// ```json
102    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
103    /// ```
104    ///
105    /// Accepts all contingency type strings: `SVCAN`, `SVC-AN`, `SVCRS`,
106    /// `SVC-RS`, `EPEC`, `FSDA`, `FS-DA`, `FSIA`, `FS-IA`, `OFFLINE`,
107    /// and their lowercase equivalents.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`FiscalError::Contingency`] if the JSON cannot be parsed or
112    /// contains an unrecognized contingency type.
113    pub fn load(json: &str) -> Result<Self, FiscalError> {
114        // Manual JSON parsing to avoid adding serde_json as a runtime dependency.
115        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        // Validate that, if a type is given, it is recognized
127        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; // Validated through contingency_type mapping
134
135        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    /// Serialize the contingency state to a JSON string.
156    ///
157    /// Produces the same format as the PHP `Contingency::__toString()`:
158    /// ```json
159    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
160    /// ```
161    ///
162    /// When deactivated, produces:
163    /// ```json
164    /// {"motive":"","timestamp":0,"type":"","tpEmis":1}
165    /// ```
166    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    /// Get the emission type code for the current contingency state.
183    ///
184    /// Returns `1` (normal) if no contingency is active, or the corresponding
185    /// `tpEmis` code: `2` (FS-IA), `4` (EPEC), `5` (FS-DA), `6` (SVC-AN),
186    /// `7` (SVC-RS), `9` (offline).
187    pub fn emission_type(&self) -> u8 {
188        match self.contingency_type {
189            Some(ct) => ct.tp_emis(),
190            None => 1,
191        }
192    }
193
194    /// Get the [`EmissionType`] enum for the current contingency state.
195    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    /// Check whether the current contingency mode has a dedicated web service.
208    ///
209    /// Only SVC-AN and SVC-RS have their own SEFAZ web services. Other types
210    /// (EPEC, FS-DA, FS-IA, offline) do not have their own web services for
211    /// NF-e authorization and will return an error if used with `sefazAutorizacao`.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`FiscalError::Contingency`] if:
216    /// - The document is model 65 (NFC-e) and an SVC contingency is active
217    ///   (NFC-e does not support SVC-AN/SVC-RS).
218    /// - The active contingency type does not have a dedicated web service
219    ///   (EPEC, FS-DA, FS-IA, offline).
220    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
258/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
259///
260/// Each state has a pre-assigned SVC authorizer:
261/// - **SVC-RS** (8 states): AM, BA, GO, MA, MS, MT, PE, PR
262/// - **SVC-AN** (19 states): all others (AC, AL, AP, CE, DF, ES, MG, PA, PB,
263///   PI, RJ, RN, RO, RR, RS, SC, SE, SP, TO)
264///
265/// # Panics
266///
267/// Panics if `uf` is not a valid 2-letter Brazilian state abbreviation.
268pub 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
277/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
278///
279/// Same as [`contingency_for_state`] but returns a `Result` instead of panicking.
280///
281/// # Errors
282///
283/// Returns [`FiscalError::InvalidStateCode`] if `uf` is not a valid 2-letter
284/// Brazilian state abbreviation.
285pub 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
294// ── Private JSON helpers ────────────────────────────────────────────────────
295
296/// Escape a string for JSON output — handles `"`, `\`, and control characters.
297pub(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                // \uXXXX for other control chars
308                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
318/// Extract a string value from a simple JSON object by key.
319/// E.g., from `{"key":"value"}` extracts "value" for key "key".
320pub(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    // Skip whitespace and colon
325    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        // String value
331        let end = content.find('"')?;
332        Some(content[..end].to_string())
333    } else {
334        None
335    }
336}
337
338/// Extract a numeric value from a simple JSON object by key.
339/// E.g., from `{"key":123}` extracts 123 for key "key".
340pub(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    // Read digits
349    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}