Skip to main content

fiscal_core/
contingency.rs

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/// Contingency manager for NF-e emission.
8///
9/// Manages activation and deactivation of contingency mode, used when the
10/// primary SEFAZ authorizer is unavailable. Supports SVC-AN, SVC-RS, and
11/// offline contingency types.
12#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub struct Contingency {
15    /// The active contingency type, or `None` when in normal mode.
16    pub contingency_type: Option<ContingencyType>,
17    /// Justification reason for entering contingency (15-256 chars).
18    pub reason: Option<String>,
19    /// ISO-8601 timestamp when contingency was activated.
20    pub activated_at: Option<String>,
21    /// Unix timestamp (seconds since epoch) of activation.
22    pub timestamp: u64,
23}
24
25impl Contingency {
26    /// Create a new contingency manager with no active contingency (normal mode).
27    pub fn new() -> Self {
28        Self {
29            contingency_type: None,
30            reason: None,
31            activated_at: None,
32            timestamp: 0,
33        }
34    }
35
36    /// Activate contingency mode with the given type and justification reason.
37    ///
38    /// The reason is trimmed and must be between 15 and 256 UTF-8 characters
39    /// (inclusive). On success, the contingency is activated with the current
40    /// UTC timestamp.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`FiscalError::Contingency`] if the trimmed reason is shorter
45    /// than 15 characters or longer than 256 characters.
46    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        // Use current UTC timestamp
60        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    /// Deactivate contingency mode, resetting to normal emission.
77    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    /// Load contingency state from a JSON string.
85    ///
86    /// Expected JSON format:
87    /// ```json
88    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
89    /// ```
90    ///
91    /// # Errors
92    ///
93    /// Returns [`FiscalError::Contingency`] if the JSON cannot be parsed or
94    /// contains an unrecognized contingency type.
95    pub fn load(json: &str) -> Result<Self, FiscalError> {
96        // Manual JSON parsing to avoid adding serde_json as a runtime dependency.
97        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; // Validated through contingency_type mapping
119
120        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    /// Get the emission type code for the current contingency state.
141    ///
142    /// Returns `1` (normal) if no contingency is active, or `6` (SVC-AN),
143    /// `7` (SVC-RS), or `9` (offline) when contingency is active.
144    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    /// Get the [`EmissionType`] enum for the current contingency state.
154    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
170/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
171///
172/// Each state has a pre-assigned SVC authorizer:
173/// - **SVC-RS** (7 states): AM, BA, GO, MA, MS, MT, PE, PR
174/// - **SVC-AN** (19 states): all others (AC, AL, AP, CE, DF, ES, MG, PA, PB,
175///   PI, RJ, RN, RO, RR, RS, SC, SE, SP, TO)
176///
177/// # Panics
178///
179/// Panics if `uf` is not a valid 2-letter Brazilian state abbreviation.
180pub 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
189/// Adjust an NF-e XML string for contingency mode.
190///
191/// Modifies the XML to:
192/// 1. Replace the `<tpEmis>` value with the contingency emission type
193/// 2. Insert `<dhCont>` (contingency datetime) and `<xJust>` (reason) inside `<ide>`
194/// 3. Recalculate the access key and check digit
195///
196/// If the contingency is not active (no type set), returns the XML unchanged.
197/// If the XML already has a non-normal `<tpEmis>` (not `1`), returns unchanged.
198///
199/// # Errors
200///
201/// Returns [`FiscalError::Contingency`] if the XML belongs to an NFC-e (model 65),
202/// since SVC-AN/SVC-RS contingency does not apply to NFC-e documents.
203///
204/// Returns [`FiscalError::XmlParsing`] if required XML tags cannot be found.
205pub fn adjust_nfe_contingency(xml: &str, contingency: &Contingency) -> Result<String, FiscalError> {
206    // If no contingency is active, return XML unchanged
207    if contingency.contingency_type.is_none() {
208        return Ok(xml.to_string());
209    }
210
211    // Remove XML signature if present
212    let mut xml = remove_signature(xml);
213
214    // Check model - must be NF-e (55), not NFC-e (65)
215    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    // Check if already in contingency mode
223    let current_tp_emis = extract_xml_tag_value(&xml, "tpEmis").unwrap_or_default();
224    if current_tp_emis != "1" {
225        // Already configured for contingency, return as-is
226        return Ok(xml);
227    }
228
229    // Extract fields for access key recalculation
230    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    // Extract emitter CNPJ or CPF from <emit> block
237    let emit_doc = extract_emitter_doc(&xml);
238
239    // Parse emission date for year/month
240    let (year, month) = parse_year_month(&dh_emi);
241
242    // Format contingency datetime with timezone from dhEmi
243    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    // Replace tpEmis value
250    xml = xml.replacen(
251        &format!("<tpEmis>{current_tp_emis}</tpEmis>"),
252        &format!("<tpEmis>{tp_emis}</tpEmis>"),
253        1,
254    );
255
256    // Insert dhCont
257    if xml.contains("<dhCont>") {
258        // Replace existing dhCont
259        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    // Insert xJust
273    if xml.contains("<xJust>") {
274        // Replace existing xJust
275        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    // Recalculate access key
289    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    // Update cDV (check digit is last char of access key)
307    let new_cdv = &new_key[new_key.len() - 1..];
308    // Replace <cDV> tag
309    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    // Update infNFe Id attribute
317    // Match pattern: Id="NFeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
318    if let Some(id_start) = xml.find("Id=\"NFe") {
319        let after_nfe = id_start + 7; // past Id="NFe
320        // Find the closing quote — the key is 44 digits
321        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
330// ── Private helpers ─────────────────────────────────────────────────────────
331
332/// Remove XML digital signature block if present.
333fn remove_signature(xml: &str) -> String {
334    // Remove <Signature xmlns...>...</Signature>
335    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
346/// Extract the emitter's CNPJ or CPF from the <emit> block.
347fn 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            // Try CNPJ first
352            if let Some(cnpj) = extract_inner(emit_block, "CNPJ") {
353                return cnpj;
354            }
355            // Then CPF
356            if let Some(cpf) = extract_inner(emit_block, "CPF") {
357                return cpf;
358            }
359        }
360    }
361    String::new()
362}
363
364/// Extract inner text from a simple XML tag.
365fn 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
373/// Parse YY and MM from an ISO datetime string like "2018-09-25T00:00:00-03:00".
374fn parse_year_month(dh_emi: &str) -> (String, String) {
375    if dh_emi.len() >= 7 {
376        let year = &dh_emi[2..4]; // "18"
377        let month = &dh_emi[5..7]; // "09"
378        (year.to_string(), month.to_string())
379    } else {
380        ("00".to_string(), "00".to_string())
381    }
382}
383
384/// Extract timezone offset from an ISO datetime string.
385/// Returns something like "-03:00". Defaults to "-03:00" if not found.
386fn extract_tz_offset(dh_emi: &str) -> String {
387    // Look for +HH:MM or -HH:MM at the end
388    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
397/// Format a unix timestamp as ISO datetime with a given timezone offset.
398fn format_timestamp_with_offset(timestamp: u64, offset: &str) -> String {
399    // Parse offset to get total seconds
400    let offset_seconds = parse_offset_seconds(offset);
401
402    // Create a chrono FixedOffset and format
403    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    // Fallback: just format as UTC
411    format!("1970-01-01T00:00:00{offset}")
412}
413
414/// Parse a timezone offset string like "-03:00" into total seconds.
415fn 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
425/// Extract a string value from a simple JSON object by key.
426/// E.g., from `{"key":"value"}` extracts "value" for key "key".
427fn 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    // Skip whitespace and colon
432    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        // String value
438        let end = content.find('"')?;
439        Some(content[..end].to_string())
440    } else {
441        None
442    }
443}
444
445/// Extract a numeric value from a simple JSON object by key.
446/// E.g., from `{"key":123}` extracts 123 for key "key".
447fn 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    // Read digits
456    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        // 1480700623 = 2016-12-02T17:43:43Z = 2016-12-02T14:43:43-03:00
568        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}