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 all contingency types
11/// defined in the NF-e specification: SVC-AN, SVC-RS, EPEC, FS-DA, FS-IA,
12/// and offline.
13///
14/// # JSON persistence
15///
16/// The state can be serialized to / deserialized from a compact JSON string
17/// using [`to_json`](Contingency::to_json) and [`load`](Contingency::load),
18/// matching the PHP `Contingency::__toString()` format:
19///
20/// ```json
21/// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
22/// ```
23#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub struct Contingency {
26    /// The active contingency type, or `None` when in normal mode.
27    pub contingency_type: Option<ContingencyType>,
28    /// Justification reason for entering contingency (15-255 chars).
29    pub reason: Option<String>,
30    /// ISO-8601 timestamp when contingency was activated.
31    pub activated_at: Option<String>,
32    /// Unix timestamp (seconds since epoch) of activation.
33    pub timestamp: u64,
34}
35
36impl Contingency {
37    /// Create a new contingency manager with no active contingency (normal mode).
38    pub fn new() -> Self {
39        Self {
40            contingency_type: None,
41            reason: None,
42            activated_at: None,
43            timestamp: 0,
44        }
45    }
46
47    /// Returns `true` when a contingency mode is currently active.
48    pub fn is_active(&self) -> bool {
49        self.contingency_type.is_some()
50    }
51
52    /// Activate contingency mode with the given type and justification reason.
53    ///
54    /// The reason is trimmed and must be between 15 and 255 UTF-8 characters
55    /// (inclusive). On success, the contingency is activated with the current
56    /// UTC timestamp.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`FiscalError::Contingency`] if the trimmed reason is shorter
61    /// than 15 characters or longer than 255 characters.
62    pub fn activate(
63        &mut self,
64        contingency_type: ContingencyType,
65        reason: &str,
66    ) -> Result<(), FiscalError> {
67        let trimmed = reason.trim();
68        let len = trimmed.chars().count();
69        if !(15..=255).contains(&len) {
70            return Err(FiscalError::Contingency(
71                "The justification for entering contingency mode must be between 15 and 255 UTF-8 characters.".to_string(),
72            ));
73        }
74
75        // Use current UTC timestamp
76        let now = std::time::SystemTime::now()
77            .duration_since(std::time::UNIX_EPOCH)
78            .unwrap_or_default()
79            .as_secs();
80
81        self.contingency_type = Some(contingency_type);
82        self.reason = Some(trimmed.to_string());
83        self.timestamp = now;
84        self.activated_at = Some(
85            chrono::DateTime::from_timestamp(now as i64, 0)
86                .unwrap_or_default()
87                .to_rfc3339(),
88        );
89        Ok(())
90    }
91
92    /// Deactivate contingency mode, resetting to normal emission.
93    pub fn deactivate(&mut self) {
94        self.contingency_type = None;
95        self.reason = None;
96        self.activated_at = None;
97        self.timestamp = 0;
98    }
99
100    /// Load contingency state from a JSON string.
101    ///
102    /// Expected JSON format (matching PHP `Contingency`):
103    /// ```json
104    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
105    /// ```
106    ///
107    /// Accepts all contingency type strings: `SVCAN`, `SVC-AN`, `SVCRS`,
108    /// `SVC-RS`, `EPEC`, `FSDA`, `FS-DA`, `FSIA`, `FS-IA`, `OFFLINE`,
109    /// and their lowercase equivalents.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`FiscalError::Contingency`] if the JSON cannot be parsed or
114    /// contains an unrecognized contingency type.
115    pub fn load(json: &str) -> Result<Self, FiscalError> {
116        // Manual JSON parsing to avoid adding serde_json as a runtime dependency.
117        let motive = extract_json_string(json, "motive")
118            .ok_or_else(|| FiscalError::Contingency("Missing 'motive' in JSON".to_string()))?;
119        let timestamp = extract_json_number(json, "timestamp")
120            .ok_or_else(|| FiscalError::Contingency("Missing 'timestamp' in JSON".to_string()))?;
121        let type_str = extract_json_string(json, "type")
122            .ok_or_else(|| FiscalError::Contingency("Missing 'type' in JSON".to_string()))?;
123        let tp_emis = extract_json_number(json, "tpEmis")
124            .ok_or_else(|| FiscalError::Contingency("Missing 'tpEmis' in JSON".to_string()))?;
125
126        let contingency_type = ContingencyType::from_type_str(&type_str);
127
128        // Validate that, if a type is given, it is recognized
129        if !type_str.is_empty() && contingency_type.is_none() {
130            return Err(FiscalError::Contingency(format!(
131                "Unrecognized contingency type: {type_str}"
132            )));
133        }
134
135        let _ = tp_emis; // Validated through contingency_type mapping
136
137        Ok(Self {
138            contingency_type,
139            reason: if motive.is_empty() {
140                None
141            } else {
142                Some(motive)
143            },
144            activated_at: if timestamp > 0 {
145                Some(
146                    chrono::DateTime::from_timestamp(timestamp as i64, 0)
147                        .unwrap_or_default()
148                        .to_rfc3339(),
149                )
150            } else {
151                None
152            },
153            timestamp,
154        })
155    }
156
157    /// Serialize the contingency state to a JSON string.
158    ///
159    /// Produces the same format as the PHP `Contingency::__toString()`:
160    /// ```json
161    /// {"motive":"reason","timestamp":1480700623,"type":"SVCAN","tpEmis":6}
162    /// ```
163    ///
164    /// When deactivated, produces:
165    /// ```json
166    /// {"motive":"","timestamp":0,"type":"","tpEmis":1}
167    /// ```
168    pub fn to_json(&self) -> String {
169        let motive = self.reason.as_deref().unwrap_or("");
170        let type_str = self
171            .contingency_type
172            .map(|ct| ct.to_type_str())
173            .unwrap_or("");
174        let tp_emis = self.emission_type();
175        format!(
176            r#"{{"motive":"{}","timestamp":{},"type":"{}","tpEmis":{}}}"#,
177            escape_json_string(motive),
178            self.timestamp,
179            type_str,
180            tp_emis
181        )
182    }
183
184    /// Get the emission type code for the current contingency state.
185    ///
186    /// Returns `1` (normal) if no contingency is active, or the corresponding
187    /// `tpEmis` code: `2` (FS-IA), `4` (EPEC), `5` (FS-DA), `6` (SVC-AN),
188    /// `7` (SVC-RS), `9` (offline).
189    pub fn emission_type(&self) -> u8 {
190        match self.contingency_type {
191            Some(ct) => ct.tp_emis(),
192            None => 1,
193        }
194    }
195
196    /// Get the [`EmissionType`] enum for the current contingency state.
197    pub fn emission_type_enum(&self) -> EmissionType {
198        match self.contingency_type {
199            Some(ContingencyType::SvcAn) => EmissionType::SvcAn,
200            Some(ContingencyType::SvcRs) => EmissionType::SvcRs,
201            Some(ContingencyType::Epec) => EmissionType::Epec,
202            Some(ContingencyType::FsDa) => EmissionType::FsDa,
203            Some(ContingencyType::FsIa) => EmissionType::FsIa,
204            Some(ContingencyType::Offline) => EmissionType::Offline,
205            None => EmissionType::Normal,
206        }
207    }
208
209    /// Check whether the current contingency mode has a dedicated web service.
210    ///
211    /// Only SVC-AN and SVC-RS have their own SEFAZ web services. Other types
212    /// (EPEC, FS-DA, FS-IA, offline) do not have their own web services for
213    /// NF-e authorization and will return an error if used with `sefazAutorizacao`.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`FiscalError::Contingency`] if:
218    /// - The document is model 65 (NFC-e) and an SVC contingency is active
219    ///   (NFC-e does not support SVC-AN/SVC-RS).
220    /// - The active contingency type does not have a dedicated web service
221    ///   (EPEC, FS-DA, FS-IA, offline).
222    pub fn check_web_service_availability(&self, model: InvoiceModel) -> Result<(), FiscalError> {
223        let ct = match self.contingency_type {
224            Some(ct) => ct,
225            None => return Ok(()),
226        };
227
228        if model == InvoiceModel::Nfce
229            && matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs)
230        {
231            return Err(FiscalError::Contingency(
232                "Não existe serviço para contingência SVCRS ou SVCAN para NFCe (modelo 65)."
233                    .to_string(),
234            ));
235        }
236
237        if !matches!(ct, ContingencyType::SvcAn | ContingencyType::SvcRs) {
238            return Err(FiscalError::Contingency(format!(
239                "Esse modo de contingência [{}] não possui webservice próprio, portanto não haverão envios.",
240                ct.to_type_str()
241            )));
242        }
243
244        Ok(())
245    }
246}
247
248impl Default for Contingency {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl core::fmt::Display for Contingency {
255    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
256        f.write_str(&self.to_json())
257    }
258}
259
260/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
261///
262/// Each state has a pre-assigned SVC authorizer:
263/// - **SVC-RS** (8 states): AM, BA, GO, MA, MS, MT, PE, PR
264/// - **SVC-AN** (19 states): all others (AC, AL, AP, CE, DF, ES, MG, PA, PB,
265///   PI, RJ, RN, RO, RR, RS, SC, SE, SP, TO)
266///
267/// # Panics
268///
269/// Panics if `uf` is not a valid 2-letter Brazilian state abbreviation.
270pub fn contingency_for_state(uf: &str) -> ContingencyType {
271    match uf {
272        "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => ContingencyType::SvcRs,
273        "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
274        | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => ContingencyType::SvcAn,
275        _ => panic!("Unknown state abbreviation: {uf}"),
276    }
277}
278
279/// Get the default contingency type (SVC-AN or SVC-RS) for a given Brazilian state.
280///
281/// Same as [`contingency_for_state`] but returns a `Result` instead of panicking.
282///
283/// # Errors
284///
285/// Returns [`FiscalError::InvalidStateCode`] if `uf` is not a valid 2-letter
286/// Brazilian state abbreviation.
287pub fn try_contingency_for_state(uf: &str) -> Result<ContingencyType, FiscalError> {
288    match uf {
289        "AM" | "BA" | "GO" | "MA" | "MS" | "MT" | "PE" | "PR" => Ok(ContingencyType::SvcRs),
290        "AC" | "AL" | "AP" | "CE" | "DF" | "ES" | "MG" | "PA" | "PB" | "PI" | "RJ" | "RN"
291        | "RO" | "RR" | "RS" | "SC" | "SE" | "SP" | "TO" => Ok(ContingencyType::SvcAn),
292        _ => Err(FiscalError::InvalidStateCode(uf.to_string())),
293    }
294}
295
296/// Adjust an NF-e XML string for contingency mode.
297///
298/// Modifies the XML to:
299/// 1. Replace the `<tpEmis>` value with the contingency emission type
300/// 2. Insert `<dhCont>` (contingency datetime) and `<xJust>` (reason) inside `<ide>`
301/// 3. Recalculate the access key and check digit
302///
303/// If the contingency is not active (no type set), returns the XML unchanged.
304/// If the XML already has a non-normal `<tpEmis>` (not `1`), returns unchanged.
305///
306/// # Errors
307///
308/// Returns [`FiscalError::Contingency`] if the XML belongs to an NFC-e (model 65),
309/// since SVC-AN/SVC-RS contingency does not apply to NFC-e documents.
310///
311/// Returns [`FiscalError::XmlParsing`] if required XML tags cannot be found.
312pub fn adjust_nfe_contingency(xml: &str, contingency: &Contingency) -> Result<String, FiscalError> {
313    // If no contingency is active, return XML unchanged
314    if contingency.contingency_type.is_none() {
315        return Ok(xml.to_string());
316    }
317
318    // Remove XML signature if present
319    let mut xml = remove_signature(xml);
320
321    // Check model - must be NF-e (55), not NFC-e (65)
322    let model = extract_xml_tag_value(&xml, "mod").unwrap_or_default();
323    if model == "65" {
324        return Err(FiscalError::Contingency(
325            "The XML belongs to a model 65 document (NFC-e), incorrect for SVCAN or SVCRS contingency.".to_string(),
326        ));
327    }
328
329    // Check if already in contingency mode
330    let current_tp_emis = extract_xml_tag_value(&xml, "tpEmis").unwrap_or_default();
331    if current_tp_emis != "1" {
332        // Already configured for contingency, return as-is
333        return Ok(xml);
334    }
335
336    // Extract fields for access key recalculation
337    let c_uf = extract_xml_tag_value(&xml, "cUF").unwrap_or_default();
338    let c_nf = extract_xml_tag_value(&xml, "cNF").unwrap_or_default();
339    let n_nf = extract_xml_tag_value(&xml, "nNF").unwrap_or_default();
340    let serie = extract_xml_tag_value(&xml, "serie").unwrap_or_default();
341    let dh_emi = extract_xml_tag_value(&xml, "dhEmi").unwrap_or_default();
342
343    // Extract emitter CNPJ or CPF from <emit> block
344    let emit_doc = extract_emitter_doc(&xml);
345
346    // Parse emission date for year/month
347    let (year, month) = parse_year_month(&dh_emi);
348
349    // Format contingency datetime with timezone from dhEmi
350    let tz_offset = extract_tz_offset(&dh_emi);
351    let dth_cont = format_timestamp_with_offset(contingency.timestamp, &tz_offset);
352
353    let reason = contingency.reason.as_deref().unwrap_or("").trim();
354    let tp_emis = contingency.emission_type();
355
356    // Replace tpEmis value
357    xml = xml.replacen(
358        &format!("<tpEmis>{current_tp_emis}</tpEmis>"),
359        &format!("<tpEmis>{tp_emis}</tpEmis>"),
360        1,
361    );
362
363    // Insert dhCont
364    if xml.contains("<dhCont>") {
365        // Replace existing dhCont
366        let re_start = xml.find("<dhCont>").unwrap();
367        let re_end = xml.find("</dhCont>").unwrap() + "</dhCont>".len();
368        xml = format!(
369            "{}<dhCont>{dth_cont}</dhCont>{}",
370            &xml[..re_start],
371            &xml[re_end..]
372        );
373    } else if xml.contains("<NFref>") {
374        xml = xml.replacen("<NFref>", &format!("<dhCont>{dth_cont}</dhCont><NFref>"), 1);
375    } else {
376        xml = xml.replacen("</ide>", &format!("<dhCont>{dth_cont}</dhCont></ide>"), 1);
377    }
378
379    // Insert xJust
380    if xml.contains("<xJust>") {
381        // Replace existing xJust
382        let re_start = xml.find("<xJust>").unwrap();
383        let re_end = xml.find("</xJust>").unwrap() + "</xJust>".len();
384        xml = format!(
385            "{}<xJust>{reason}</xJust>{}",
386            &xml[..re_start],
387            &xml[re_end..]
388        );
389    } else if xml.contains("<NFref>") {
390        xml = xml.replacen("<NFref>", &format!("<xJust>{reason}</xJust><NFref>"), 1);
391    } else {
392        xml = xml.replacen("</ide>", &format!("<xJust>{reason}</xJust></ide>"), 1);
393    }
394
395    // Recalculate access key
396    let model_enum = match model.as_str() {
397        "65" => InvoiceModel::Nfce,
398        _ => InvoiceModel::Nfe,
399    };
400    let emission_type_enum = contingency.emission_type_enum();
401
402    let new_key = build_access_key(&AccessKeyParams {
403        state_code: IbgeCode(c_uf),
404        year_month: format!("{year}{month}"),
405        tax_id: emit_doc,
406        model: model_enum,
407        series: serie.parse().unwrap_or(0),
408        number: n_nf.parse().unwrap_or(0),
409        emission_type: emission_type_enum,
410        numeric_code: c_nf,
411    })?;
412
413    // Update cDV (check digit is last char of access key)
414    let new_cdv = &new_key[new_key.len() - 1..];
415    // Replace <cDV> tag
416    if let Some(start) = xml.find("<cDV>") {
417        if let Some(end) = xml[start..].find("</cDV>") {
418            let full_end = start + end + "</cDV>".len();
419            xml = format!("{}<cDV>{new_cdv}</cDV>{}", &xml[..start], &xml[full_end..]);
420        }
421    }
422
423    // Update infNFe Id attribute
424    // Match pattern: Id="NFeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
425    if let Some(id_start) = xml.find("Id=\"NFe") {
426        let after_nfe = id_start + 7; // past Id="NFe
427        // Find the closing quote — the key is 44 digits
428        if xml.len() >= after_nfe + 44 {
429            let id_end = after_nfe + 44;
430            xml = format!("{}NFe{new_key}{}", &xml[..after_nfe], &xml[id_end..]);
431        }
432    }
433
434    Ok(xml)
435}
436
437// ── Private helpers ─────────────────────────────────────────────────────────
438
439/// Remove XML digital signature block if present.
440fn remove_signature(xml: &str) -> String {
441    // Remove <Signature xmlns...>...</Signature>
442    if let Some(start) = xml.find("<Signature") {
443        if let Some(end) = xml.find("</Signature>") {
444            let full_end = end + "</Signature>".len();
445            return format!("{}{}", xml[..start].trim_end(), &xml[full_end..])
446                .trim()
447                .to_string();
448        }
449    }
450    xml.to_string()
451}
452
453/// Extract the emitter's CNPJ or CPF from the <emit> block.
454fn extract_emitter_doc(xml: &str) -> String {
455    if let Some(emit_start) = xml.find("<emit>") {
456        if let Some(emit_end) = xml.find("</emit>") {
457            let emit_block = &xml[emit_start..emit_end];
458            // Try CNPJ first
459            if let Some(cnpj) = extract_inner(emit_block, "CNPJ") {
460                return cnpj;
461            }
462            // Then CPF
463            if let Some(cpf) = extract_inner(emit_block, "CPF") {
464                return cpf;
465            }
466        }
467    }
468    String::new()
469}
470
471/// Extract inner text from a simple XML tag.
472fn extract_inner(xml: &str, tag: &str) -> Option<String> {
473    let open = format!("<{tag}>");
474    let close = format!("</{tag}>");
475    let start = xml.find(&open)? + open.len();
476    let end = xml[start..].find(&close)? + start;
477    Some(xml[start..end].to_string())
478}
479
480/// Parse YY and MM from an ISO datetime string like "2018-09-25T00:00:00-03:00".
481fn parse_year_month(dh_emi: &str) -> (String, String) {
482    if dh_emi.len() >= 7 {
483        let year = &dh_emi[2..4]; // "18"
484        let month = &dh_emi[5..7]; // "09"
485        (year.to_string(), month.to_string())
486    } else {
487        ("00".to_string(), "00".to_string())
488    }
489}
490
491/// Extract timezone offset from an ISO datetime string.
492/// Returns something like "-03:00". Defaults to "-03:00" if not found.
493fn extract_tz_offset(dh_emi: &str) -> String {
494    // Look for +HH:MM or -HH:MM at the end
495    if dh_emi.len() >= 6 {
496        let tail = &dh_emi[dh_emi.len() - 6..];
497        if (tail.starts_with('+') || tail.starts_with('-')) && tail.as_bytes()[3] == b':' {
498            return tail.to_string();
499        }
500    }
501    "-03:00".to_string()
502}
503
504/// Format a unix timestamp as ISO datetime with a given timezone offset.
505fn format_timestamp_with_offset(timestamp: u64, offset: &str) -> String {
506    // Parse offset to get total seconds
507    let offset_seconds = parse_offset_seconds(offset);
508
509    // Create a chrono FixedOffset and format
510    if let Some(fo) = chrono::FixedOffset::east_opt(offset_seconds) {
511        if let Some(dt) = chrono::DateTime::from_timestamp(timestamp as i64, 0) {
512            let local = dt.with_timezone(&fo);
513            return local.format("%Y-%m-%dT%H:%M:%S").to_string() + offset;
514        }
515    }
516
517    // Fallback: just format as UTC
518    format!("1970-01-01T00:00:00{offset}")
519}
520
521/// Parse a timezone offset string like "-03:00" into total seconds.
522fn parse_offset_seconds(offset: &str) -> i32 {
523    if offset.len() < 6 {
524        return 0;
525    }
526    let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
527    let hours: i32 = offset[1..3].parse().unwrap_or(0);
528    let minutes: i32 = offset[4..6].parse().unwrap_or(0);
529    sign * (hours * 3600 + minutes * 60)
530}
531
532/// Escape a string for JSON output — handles `"`, `\`, and control characters.
533fn escape_json_string(s: &str) -> String {
534    let mut out = String::with_capacity(s.len());
535    for c in s.chars() {
536        match c {
537            '"' => out.push_str("\\\""),
538            '\\' => out.push_str("\\\\"),
539            '\n' => out.push_str("\\n"),
540            '\r' => out.push_str("\\r"),
541            '\t' => out.push_str("\\t"),
542            c if c.is_control() => {
543                // \uXXXX for other control chars
544                for unit in c.encode_utf16(&mut [0; 2]) {
545                    out.push_str(&format!("\\u{unit:04x}"));
546                }
547            }
548            _ => out.push(c),
549        }
550    }
551    out
552}
553
554/// Extract a string value from a simple JSON object by key.
555/// E.g., from `{"key":"value"}` extracts "value" for key "key".
556fn extract_json_string(json: &str, key: &str) -> Option<String> {
557    let search = format!("\"{key}\"");
558    let idx = json.find(&search)?;
559    let after_key = idx + search.len();
560    // Skip whitespace and colon
561    let rest = json[after_key..].trim_start();
562    let rest = rest.strip_prefix(':')?;
563    let rest = rest.trim_start();
564
565    if let Some(content) = rest.strip_prefix('"') {
566        // String value
567        let end = content.find('"')?;
568        Some(content[..end].to_string())
569    } else {
570        None
571    }
572}
573
574/// Extract a numeric value from a simple JSON object by key.
575/// E.g., from `{"key":123}` extracts 123 for key "key".
576fn extract_json_number(json: &str, key: &str) -> Option<u64> {
577    let search = format!("\"{key}\"");
578    let idx = json.find(&search)?;
579    let after_key = idx + search.len();
580    let rest = json[after_key..].trim_start();
581    let rest = rest.strip_prefix(':')?;
582    let rest = rest.trim_start();
583
584    // Read digits
585    let end = rest
586        .find(|c: char| !c.is_ascii_digit())
587        .unwrap_or(rest.len());
588    if end == 0 {
589        return None;
590    }
591    rest[..end].parse().ok()
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn new_contingency_is_inactive() {
600        let c = Contingency::new();
601        assert!(c.contingency_type.is_none());
602        assert!(!c.is_active());
603        assert_eq!(c.emission_type(), 1);
604    }
605
606    #[test]
607    fn default_is_inactive() {
608        let c = Contingency::default();
609        assert!(c.contingency_type.is_none());
610        assert!(!c.is_active());
611    }
612
613    #[test]
614    fn activate_sets_fields() {
615        let mut c = Contingency::new();
616        c.activate(
617            ContingencyType::SvcAn,
618            "A valid reason for contingency mode activation",
619        )
620        .unwrap();
621        assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
622        assert_eq!(c.emission_type(), 6);
623        assert!(c.is_active());
624        assert!(c.reason.is_some());
625        assert!(c.activated_at.is_some());
626    }
627
628    #[test]
629    fn activate_svc_rs() {
630        let mut c = Contingency::new();
631        c.activate(
632            ContingencyType::SvcRs,
633            "A valid reason for contingency mode activation",
634        )
635        .unwrap();
636        assert_eq!(c.emission_type(), 7);
637        assert_eq!(c.emission_type_enum(), EmissionType::SvcRs);
638    }
639
640    #[test]
641    fn activate_offline() {
642        let mut c = Contingency::new();
643        c.activate(
644            ContingencyType::Offline,
645            "A valid reason for contingency mode activation",
646        )
647        .unwrap();
648        assert_eq!(c.emission_type(), 9);
649        assert_eq!(c.emission_type_enum(), EmissionType::Offline);
650    }
651
652    #[test]
653    fn activate_epec() {
654        let mut c = Contingency::new();
655        c.activate(
656            ContingencyType::Epec,
657            "A valid reason for contingency mode activation",
658        )
659        .unwrap();
660        assert_eq!(c.emission_type(), 4);
661        assert_eq!(c.emission_type_enum(), EmissionType::Epec);
662    }
663
664    #[test]
665    fn activate_fs_da() {
666        let mut c = Contingency::new();
667        c.activate(
668            ContingencyType::FsDa,
669            "A valid reason for contingency mode activation",
670        )
671        .unwrap();
672        assert_eq!(c.emission_type(), 5);
673        assert_eq!(c.emission_type_enum(), EmissionType::FsDa);
674    }
675
676    #[test]
677    fn activate_fs_ia() {
678        let mut c = Contingency::new();
679        c.activate(
680            ContingencyType::FsIa,
681            "A valid reason for contingency mode activation",
682        )
683        .unwrap();
684        assert_eq!(c.emission_type(), 2);
685        assert_eq!(c.emission_type_enum(), EmissionType::FsIa);
686    }
687
688    #[test]
689    fn activate_rejects_short_reason() {
690        let mut c = Contingency::new();
691        let result = c.activate(ContingencyType::SvcAn, "Short");
692        assert!(result.is_err());
693    }
694
695    #[test]
696    fn activate_rejects_long_reason() {
697        let mut c = Contingency::new();
698        let motive = "A".repeat(256);
699        let result = c.activate(ContingencyType::SvcAn, &motive);
700        assert!(result.is_err());
701    }
702
703    #[test]
704    fn activate_accepts_255_char_reason() {
705        let mut c = Contingency::new();
706        let motive = "A".repeat(255);
707        let result = c.activate(ContingencyType::SvcAn, &motive);
708        assert!(result.is_ok(), "255-char reason must be accepted");
709    }
710
711    #[test]
712    fn activate_rejects_256_char_reason() {
713        let mut c = Contingency::new();
714        let motive = "A".repeat(256);
715        let result = c.activate(ContingencyType::SvcAn, &motive);
716        assert!(result.is_err(), "256-char reason must be rejected");
717    }
718
719    #[test]
720    fn deactivate_clears_state() {
721        let mut c = Contingency::new();
722        c.activate(
723            ContingencyType::SvcAn,
724            "A valid reason for contingency mode activation",
725        )
726        .unwrap();
727        c.deactivate();
728        assert!(c.contingency_type.is_none());
729        assert!(!c.is_active());
730        assert_eq!(c.emission_type(), 1);
731        assert_eq!(c.emission_type_enum(), EmissionType::Normal);
732    }
733
734    #[test]
735    fn load_from_json() {
736        let json =
737            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
738        let c = Contingency::load(json).unwrap();
739        assert_eq!(c.contingency_type, Some(ContingencyType::SvcAn));
740        assert_eq!(c.emission_type(), 6);
741        assert_eq!(c.reason.as_deref(), Some("Testes Unitarios"));
742        assert!(c.is_active());
743    }
744
745    #[test]
746    fn load_svc_rs_from_json() {
747        let json =
748            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
749        let c = Contingency::load(json).unwrap();
750        assert_eq!(c.contingency_type, Some(ContingencyType::SvcRs));
751        assert_eq!(c.emission_type(), 7);
752    }
753
754    #[test]
755    fn load_epec_from_json() {
756        let json =
757            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"EPEC","tpEmis":4}"#;
758        let c = Contingency::load(json).unwrap();
759        assert_eq!(c.contingency_type, Some(ContingencyType::Epec));
760        assert_eq!(c.emission_type(), 4);
761    }
762
763    #[test]
764    fn load_fs_da_from_json() {
765        let json =
766            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSDA","tpEmis":5}"#;
767        let c = Contingency::load(json).unwrap();
768        assert_eq!(c.contingency_type, Some(ContingencyType::FsDa));
769        assert_eq!(c.emission_type(), 5);
770    }
771
772    #[test]
773    fn load_fs_ia_from_json() {
774        let json =
775            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"FSIA","tpEmis":2}"#;
776        let c = Contingency::load(json).unwrap();
777        assert_eq!(c.contingency_type, Some(ContingencyType::FsIa));
778        assert_eq!(c.emission_type(), 2);
779    }
780
781    #[test]
782    fn load_offline_from_json() {
783        let json =
784            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"OFFLINE","tpEmis":9}"#;
785        let c = Contingency::load(json).unwrap();
786        assert_eq!(c.contingency_type, Some(ContingencyType::Offline));
787        assert_eq!(c.emission_type(), 9);
788    }
789
790    #[test]
791    fn load_deactivated_from_json() {
792        let json = r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#;
793        let c = Contingency::load(json).unwrap();
794        assert!(c.contingency_type.is_none());
795        assert!(!c.is_active());
796        assert_eq!(c.emission_type(), 1);
797    }
798
799    #[test]
800    fn load_rejects_unknown_type() {
801        let json =
802            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"UNKNOWN","tpEmis":1}"#;
803        let result = Contingency::load(json);
804        assert!(result.is_err());
805    }
806
807    #[test]
808    fn to_json_activated() {
809        let json =
810            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
811        let c = Contingency::load(json).unwrap();
812        assert_eq!(c.to_json(), json);
813    }
814
815    #[test]
816    fn to_json_deactivated() {
817        let c = Contingency::new();
818        assert_eq!(
819            c.to_json(),
820            r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
821        );
822    }
823
824    #[test]
825    fn to_json_roundtrip() {
826        let json =
827            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCRS","tpEmis":7}"#;
828        let c = Contingency::load(json).unwrap();
829        let output = c.to_json();
830        assert_eq!(output, json);
831        // Load again and verify
832        let c2 = Contingency::load(&output).unwrap();
833        assert_eq!(c2.contingency_type, c.contingency_type);
834        assert_eq!(c2.reason, c.reason);
835        assert_eq!(c2.timestamp, c.timestamp);
836    }
837
838    #[test]
839    fn deactivate_produces_correct_json() {
840        let json =
841            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
842        let mut c = Contingency::load(json).unwrap();
843        c.deactivate();
844        assert_eq!(
845            c.to_json(),
846            r#"{"motive":"","timestamp":0,"type":"","tpEmis":1}"#
847        );
848    }
849
850    #[test]
851    fn display_matches_to_json() {
852        let json =
853            r#"{"motive":"Testes Unitarios","timestamp":1480700623,"type":"SVCAN","tpEmis":6}"#;
854        let c = Contingency::load(json).unwrap();
855        assert_eq!(format!("{c}"), c.to_json());
856    }
857
858    #[test]
859    fn extract_json_string_works() {
860        let json = r#"{"motive":"hello world","type":"SVCAN"}"#;
861        assert_eq!(
862            extract_json_string(json, "motive"),
863            Some("hello world".to_string())
864        );
865        assert_eq!(extract_json_string(json, "type"), Some("SVCAN".to_string()));
866    }
867
868    #[test]
869    fn extract_json_number_works() {
870        let json = r#"{"timestamp":1480700623,"tpEmis":6}"#;
871        assert_eq!(extract_json_number(json, "timestamp"), Some(1480700623));
872        assert_eq!(extract_json_number(json, "tpEmis"), Some(6));
873    }
874
875    #[test]
876    fn format_timestamp_with_offset_formats_correctly() {
877        // 1480700623 = 2016-12-02T17:43:43Z = 2016-12-02T14:43:43-03:00
878        let result = format_timestamp_with_offset(1480700623, "-03:00");
879        assert_eq!(result, "2016-12-02T14:43:43-03:00");
880    }
881
882    #[test]
883    fn contingency_for_state_sp() {
884        assert_eq!(contingency_for_state("SP").as_str(), "svc-an");
885    }
886
887    #[test]
888    fn contingency_for_state_am() {
889        assert_eq!(contingency_for_state("AM").as_str(), "svc-rs");
890    }
891
892    #[test]
893    fn try_contingency_for_state_valid() {
894        assert_eq!(
895            try_contingency_for_state("SP").unwrap(),
896            ContingencyType::SvcAn
897        );
898        assert_eq!(
899            try_contingency_for_state("AM").unwrap(),
900            ContingencyType::SvcRs
901        );
902    }
903
904    #[test]
905    fn try_contingency_for_state_invalid() {
906        assert!(try_contingency_for_state("XX").is_err());
907    }
908
909    #[test]
910    fn check_web_service_nfe_svc_an_ok() {
911        let mut c = Contingency::new();
912        c.activate(
913            ContingencyType::SvcAn,
914            "A valid reason for contingency mode activation",
915        )
916        .unwrap();
917        assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
918    }
919
920    #[test]
921    fn check_web_service_nfe_svc_rs_ok() {
922        let mut c = Contingency::new();
923        c.activate(
924            ContingencyType::SvcRs,
925            "A valid reason for contingency mode activation",
926        )
927        .unwrap();
928        assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
929    }
930
931    #[test]
932    fn check_web_service_nfce_svc_fails() {
933        let mut c = Contingency::new();
934        c.activate(
935            ContingencyType::SvcAn,
936            "A valid reason for contingency mode activation",
937        )
938        .unwrap();
939        assert!(
940            c.check_web_service_availability(InvoiceModel::Nfce)
941                .is_err()
942        );
943    }
944
945    #[test]
946    fn check_web_service_epec_no_webservice() {
947        let mut c = Contingency::new();
948        c.activate(
949            ContingencyType::Epec,
950            "A valid reason for contingency mode activation",
951        )
952        .unwrap();
953        let err = c
954            .check_web_service_availability(InvoiceModel::Nfe)
955            .unwrap_err();
956        assert!(err.to_string().contains("EPEC"));
957    }
958
959    #[test]
960    fn check_web_service_normal_mode_ok() {
961        let c = Contingency::new();
962        assert!(c.check_web_service_availability(InvoiceModel::Nfe).is_ok());
963        assert!(c.check_web_service_availability(InvoiceModel::Nfce).is_ok());
964    }
965
966    #[test]
967    fn contingency_type_display() {
968        assert_eq!(format!("{}", ContingencyType::SvcAn), "SVCAN");
969        assert_eq!(format!("{}", ContingencyType::SvcRs), "SVCRS");
970        assert_eq!(format!("{}", ContingencyType::Epec), "EPEC");
971        assert_eq!(format!("{}", ContingencyType::FsDa), "FSDA");
972        assert_eq!(format!("{}", ContingencyType::FsIa), "FSIA");
973        assert_eq!(format!("{}", ContingencyType::Offline), "OFFLINE");
974    }
975
976    #[test]
977    fn contingency_type_from_str() {
978        assert_eq!(
979            "SVCAN".parse::<ContingencyType>().unwrap(),
980            ContingencyType::SvcAn
981        );
982        assert_eq!(
983            "SVC-AN".parse::<ContingencyType>().unwrap(),
984            ContingencyType::SvcAn
985        );
986        assert_eq!(
987            "SVCRS".parse::<ContingencyType>().unwrap(),
988            ContingencyType::SvcRs
989        );
990        assert_eq!(
991            "EPEC".parse::<ContingencyType>().unwrap(),
992            ContingencyType::Epec
993        );
994        assert_eq!(
995            "FSDA".parse::<ContingencyType>().unwrap(),
996            ContingencyType::FsDa
997        );
998        assert_eq!(
999            "FSIA".parse::<ContingencyType>().unwrap(),
1000            ContingencyType::FsIa
1001        );
1002        assert_eq!(
1003            "OFFLINE".parse::<ContingencyType>().unwrap(),
1004            ContingencyType::Offline
1005        );
1006        assert!("UNKNOWN".parse::<ContingencyType>().is_err());
1007    }
1008
1009    #[test]
1010    fn contingency_type_from_tp_emis() {
1011        assert_eq!(
1012            ContingencyType::from_tp_emis(2),
1013            Some(ContingencyType::FsIa)
1014        );
1015        assert_eq!(
1016            ContingencyType::from_tp_emis(4),
1017            Some(ContingencyType::Epec)
1018        );
1019        assert_eq!(
1020            ContingencyType::from_tp_emis(5),
1021            Some(ContingencyType::FsDa)
1022        );
1023        assert_eq!(
1024            ContingencyType::from_tp_emis(6),
1025            Some(ContingencyType::SvcAn)
1026        );
1027        assert_eq!(
1028            ContingencyType::from_tp_emis(7),
1029            Some(ContingencyType::SvcRs)
1030        );
1031        assert_eq!(
1032            ContingencyType::from_tp_emis(9),
1033            Some(ContingencyType::Offline)
1034        );
1035        assert_eq!(ContingencyType::from_tp_emis(1), None);
1036        assert_eq!(ContingencyType::from_tp_emis(0), None);
1037        assert_eq!(ContingencyType::from_tp_emis(3), None);
1038    }
1039
1040    #[test]
1041    fn escape_json_string_basic() {
1042        assert_eq!(escape_json_string("hello"), "hello");
1043        assert_eq!(escape_json_string(r#"say "hi""#), r#"say \"hi\""#);
1044        assert_eq!(escape_json_string("a\\b"), "a\\\\b");
1045    }
1046
1047    #[test]
1048    #[should_panic(expected = "Unknown state abbreviation")]
1049    fn contingency_for_state_unknown_panics() {
1050        contingency_for_state("XX");
1051    }
1052
1053    #[test]
1054    fn display_for_contingency_matches_to_json() {
1055        let c = Contingency::new();
1056        assert_eq!(c.to_string(), c.to_json());
1057    }
1058
1059    // ── adjust_nfe_contingency tests ────────────────────────────────
1060
1061    #[test]
1062    fn adjust_nfe_contingency_inactive_returns_unchanged() {
1063        let c = Contingency::new();
1064        let xml = "<NFe><infNFe/></NFe>";
1065        let result = adjust_nfe_contingency(xml, &c).unwrap();
1066        assert_eq!(result, xml);
1067    }
1068
1069    #[test]
1070    fn adjust_nfe_contingency_model65_returns_error() {
1071        let mut c = Contingency::new();
1072        c.activate(
1073            ContingencyType::SvcAn,
1074            "Motivo de contingencia teste valido",
1075        )
1076        .unwrap();
1077        let xml = "<NFe><infNFe><ide><mod>65</mod><tpEmis>1</tpEmis></ide></infNFe></NFe>";
1078        let err = adjust_nfe_contingency(xml, &c).unwrap_err();
1079        assert!(matches!(err, FiscalError::Contingency(_)));
1080    }
1081
1082    #[test]
1083    fn adjust_nfe_contingency_already_non_normal_returns_unchanged() {
1084        let mut c = Contingency::new();
1085        c.activate(
1086            ContingencyType::SvcAn,
1087            "Motivo de contingencia teste valido",
1088        )
1089        .unwrap();
1090        let xml = "<NFe><infNFe><ide><mod>55</mod><tpEmis>6</tpEmis></ide></infNFe></NFe>";
1091        let result = adjust_nfe_contingency(xml, &c).unwrap();
1092        assert!(result.contains("<tpEmis>6</tpEmis>"));
1093    }
1094
1095    #[test]
1096    fn adjust_nfe_contingency_replaces_tp_emis_and_inserts_dh_cont() {
1097        let mut c = Contingency::new();
1098        c.activate(
1099            ContingencyType::SvcAn,
1100            "Motivo de contingencia teste valido",
1101        )
1102        .unwrap();
1103        let xml = concat!(
1104            r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1105            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1106            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1107            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1108            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1109            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1110            "<tpAmb>2</tpAmb></ide>",
1111            "<emit><CNPJ>04123456000190</CNPJ></emit>",
1112            "</infNFe></NFe>"
1113        );
1114        let result = adjust_nfe_contingency(xml, &c).unwrap();
1115        assert!(result.contains("<tpEmis>6</tpEmis>"));
1116        assert!(result.contains("<dhCont>"));
1117        assert!(result.contains("<xJust>"));
1118    }
1119
1120    #[test]
1121    fn adjust_nfe_contingency_replaces_existing_dh_cont() {
1122        let mut c = Contingency::new();
1123        c.activate(
1124            ContingencyType::SvcAn,
1125            "Motivo de contingencia teste valido",
1126        )
1127        .unwrap();
1128        let xml = concat!(
1129            r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1130            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1131            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1132            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1133            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1134            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1135            "<tpAmb>2</tpAmb>",
1136            "<dhCont>2020-01-01T00:00:00-03:00</dhCont>",
1137            "<xJust>old reason</xJust>",
1138            "</ide>",
1139            "<emit><CNPJ>04123456000190</CNPJ></emit>",
1140            "</infNFe></NFe>"
1141        );
1142        let result = adjust_nfe_contingency(xml, &c).unwrap();
1143        assert!(result.contains("<tpEmis>6</tpEmis>"));
1144        assert!(!result.contains("old reason"));
1145        assert!(result.contains("Motivo de contingencia teste valido"));
1146    }
1147
1148    #[test]
1149    fn adjust_nfe_contingency_inserts_before_nfref() {
1150        let mut c = Contingency::new();
1151        c.activate(
1152            ContingencyType::SvcRs,
1153            "Motivo de contingencia teste valido para NFRef",
1154        )
1155        .unwrap();
1156        let xml = concat!(
1157            r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1158            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1159            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1160            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1161            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1162            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1163            "<tpAmb>2</tpAmb><NFref><refNFe>123</refNFe></NFref></ide>",
1164            "<emit><CNPJ>04123456000190</CNPJ></emit>",
1165            "</infNFe></NFe>"
1166        );
1167        let result = adjust_nfe_contingency(xml, &c).unwrap();
1168        assert!(result.contains("<tpEmis>7</tpEmis>"));
1169        // dhCont and xJust should appear before <NFref>
1170        let dh_pos = result.find("<dhCont>").unwrap();
1171        let nfref_pos = result.find("<NFref>").unwrap();
1172        assert!(dh_pos < nfref_pos);
1173    }
1174
1175    #[test]
1176    fn adjust_nfe_contingency_removes_signature() {
1177        let mut c = Contingency::new();
1178        c.activate(
1179            ContingencyType::SvcAn,
1180            "Motivo de contingencia teste valido",
1181        )
1182        .unwrap();
1183        let xml = concat!(
1184            r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
1185            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
1186            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
1187            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
1188            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
1189            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
1190            "<tpAmb>2</tpAmb></ide>",
1191            "<emit><CNPJ>04123456000190</CNPJ></emit>",
1192            "</infNFe>",
1193            r#"<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo/></Signature>"#,
1194            "</NFe>"
1195        );
1196        let result = adjust_nfe_contingency(xml, &c).unwrap();
1197        assert!(!result.contains("<Signature"));
1198    }
1199
1200    #[test]
1201    fn extract_emitter_doc_cpf() {
1202        let xml = "<root><emit><CPF>12345678901</CPF></emit></root>";
1203        assert_eq!(extract_emitter_doc(xml), "12345678901");
1204    }
1205
1206    #[test]
1207    fn extract_emitter_doc_no_emit() {
1208        let xml = "<root><other/></root>";
1209        assert_eq!(extract_emitter_doc(xml), "");
1210    }
1211
1212    #[test]
1213    fn parse_year_month_short_input() {
1214        let (y, m) = parse_year_month("2026");
1215        assert_eq!(y, "00");
1216        assert_eq!(m, "00");
1217    }
1218
1219    #[test]
1220    fn extract_tz_offset_no_offset() {
1221        assert_eq!(extract_tz_offset("2026"), "-03:00");
1222    }
1223
1224    #[test]
1225    fn format_timestamp_with_offset_bad_offset() {
1226        // Very short offset, should fall through to fallback
1227        let result = format_timestamp_with_offset(0, "X");
1228        assert!(result.contains("1970"));
1229    }
1230
1231    #[test]
1232    fn escape_json_string_control_chars() {
1233        let s = escape_json_string("a\nb\tc\rd");
1234        assert_eq!(s, "a\\nb\\tc\\rd");
1235    }
1236
1237    #[test]
1238    fn all_27_states_have_mapping() {
1239        let states = [
1240            "AC", "AL", "AM", "AP", "BA", "CE", "DF", "ES", "GO", "MA", "MG", "MS", "MT", "PA",
1241            "PB", "PE", "PI", "PR", "RJ", "RN", "RO", "RR", "RS", "SC", "SE", "SP", "TO",
1242        ];
1243        for uf in states {
1244            let ct = contingency_for_state(uf);
1245            assert!(
1246                ct == ContingencyType::SvcAn || ct == ContingencyType::SvcRs,
1247                "State {uf} should map to SVC-AN or SVC-RS"
1248            );
1249        }
1250    }
1251}