Skip to main content

fiscal_core/
config.rs

1//! Configuration validation for Brazilian fiscal documents.
2//!
3//! This module mirrors the PHP `Config::validate()` method from sped-nfe,
4//! which parses a JSON string against a schema and returns a validated
5//! configuration object.
6//!
7//! # Required fields
8//!
9//! | Field         | Type    | Constraint                          |
10//! |---------------|---------|-------------------------------------|
11//! | `tpAmb`       | integer | 1 (produção) or 2 (homologação)     |
12//! | `razaosocial` | string  | non-empty                           |
13//! | `siglaUF`     | string  | exactly 2 characters, valid UF      |
14//! | `cnpj`        | string  | 11 digits (CPF) or 14 digits (CNPJ) |
15//! | `schemes`     | string  | non-empty                           |
16//! | `versao`      | string  | non-empty                           |
17//!
18//! # Optional fields
19//!
20//! `atualizacao`, `tokenIBPT`, `CSC`, `CSCid`, and `aProxyConf` are all
21//! optional and may be omitted or set to `null`.
22//!
23//! # Example
24//!
25//! ```
26//! use fiscal_core::config::validate_config;
27//!
28//! let json = r#"{
29//!     "tpAmb": 2,
30//!     "razaosocial": "EMPRESA LTDA",
31//!     "siglaUF": "SP",
32//!     "cnpj": "93623057000128",
33//!     "schemes": "PL_009_V4",
34//!     "versao": "4.00"
35//! }"#;
36//!
37//! let config = validate_config(json).unwrap();
38//! assert_eq!(config.tp_amb, 2);
39//! assert_eq!(config.sigla_uf, "SP");
40//! ```
41
42use serde::Deserialize;
43
44use crate::FiscalError;
45
46/// Proxy configuration for SEFAZ communication.
47///
48/// All fields are optional; when present they configure HTTP proxy settings.
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ProxyConfig {
52    /// Proxy server IP address.
53    #[serde(default, deserialize_with = "deserialize_null_string")]
54    pub proxy_ip: Option<String>,
55    /// Proxy server port.
56    #[serde(default, deserialize_with = "deserialize_null_string")]
57    pub proxy_port: Option<String>,
58    /// Proxy authentication user.
59    #[serde(default, deserialize_with = "deserialize_null_string")]
60    pub proxy_user: Option<String>,
61    /// Proxy authentication password.
62    #[serde(default, deserialize_with = "deserialize_null_string")]
63    pub proxy_pass: Option<String>,
64}
65
66/// Intermediate struct for serde deserialization (matches PHP JSON field names).
67#[derive(Deserialize)]
68#[serde(rename_all = "camelCase")]
69struct RawConfig {
70    atualizacao: Option<String>,
71    #[serde(alias = "tpAmb")]
72    tp_amb: Option<u8>,
73    razaosocial: Option<String>,
74    #[serde(alias = "siglaUF")]
75    sigla_uf: Option<String>,
76    cnpj: Option<String>,
77    schemes: Option<String>,
78    versao: Option<String>,
79    #[serde(
80        default,
81        alias = "tokenIBPT",
82        deserialize_with = "deserialize_null_string"
83    )]
84    token_ibpt: Option<String>,
85    #[serde(default, rename = "CSC", deserialize_with = "deserialize_null_string")]
86    csc: Option<String>,
87    #[serde(
88        default,
89        rename = "CSCid",
90        deserialize_with = "deserialize_null_string"
91    )]
92    csc_id: Option<String>,
93    #[serde(default, rename = "aProxyConf")]
94    a_proxy_conf: Option<ProxyConfig>,
95}
96
97/// Validated fiscal configuration.
98///
99/// Created by [`validate_config`] after parsing and validating a JSON string
100/// against the same rules as the PHP `Config::validate()` method.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FiscalConfig {
103    /// Date/time of the last configuration update (optional).
104    pub atualizacao: Option<String>,
105    /// Environment type: 1 = production, 2 = homologation.
106    pub tp_amb: u8,
107    /// Company legal name.
108    pub razaosocial: String,
109    /// Two-letter Brazilian state abbreviation (UF).
110    pub sigla_uf: String,
111    /// CNPJ (14 digits) or CPF (11 digits).
112    pub cnpj: String,
113    /// Schema version path (e.g. `"PL_009_V4"`).
114    pub schemes: String,
115    /// NF-e layout version (e.g. `"4.00"`).
116    pub versao: String,
117    /// IBPT transparency token (optional).
118    pub token_ibpt: Option<String>,
119    /// NFC-e security code (optional).
120    pub csc: Option<String>,
121    /// NFC-e security code ID (optional).
122    pub csc_id: Option<String>,
123    /// Proxy configuration (optional).
124    pub a_proxy_conf: Option<ProxyConfig>,
125}
126
127/// Parse and validate a JSON configuration string.
128///
129/// This is the Rust equivalent of the PHP `Config::validate($content)` method.
130/// It performs the following checks:
131///
132/// 1. The input must be valid JSON.
133/// 2. The JSON root must be an object (not an array or scalar).
134/// 3. All required fields must be present and non-null.
135/// 4. `tpAmb` must be 1 or 2.
136/// 5. `siglaUF` must be exactly 2 characters.
137/// 6. `cnpj` must be 11 or 14 digits (CPF or CNPJ).
138///
139/// # Errors
140///
141/// Returns [`FiscalError::ConfigValidation`] if any validation rule fails.
142pub fn validate_config(json: &str) -> Result<FiscalConfig, FiscalError> {
143    if json.is_empty() {
144        return Err(FiscalError::ConfigValidation(
145            "Não foi passado um json válido.".into(),
146        ));
147    }
148
149    // First check: must be a JSON object
150    let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
151        FiscalError::ConfigValidation(format!("Não foi passado um json válido: {e}"))
152    })?;
153
154    if !value.is_object() {
155        return Err(FiscalError::ConfigValidation(
156            "Não foi passado um json válido.".into(),
157        ));
158    }
159
160    // Deserialize into raw struct
161    let raw: RawConfig = serde_json::from_value(value).map_err(|e| {
162        FiscalError::ConfigValidation(format!("Erro ao deserializar configuração: {e}"))
163    })?;
164
165    // Validate required fields
166    let mut errors = Vec::new();
167
168    let tp_amb = match raw.tp_amb {
169        Some(v) => v,
170        None => {
171            errors.push("[tpAmb] Campo obrigatório".to_string());
172            0
173        }
174    };
175
176    let razaosocial = match raw.razaosocial {
177        Some(ref v) if !v.is_empty() => v.clone(),
178        Some(_) => {
179            errors.push("[razaosocial] Campo obrigatório".to_string());
180            String::new()
181        }
182        None => {
183            errors.push("[razaosocial] Campo obrigatório".to_string());
184            String::new()
185        }
186    };
187
188    let sigla_uf = match raw.sigla_uf {
189        Some(ref v) if !v.is_empty() => v.clone(),
190        Some(_) => {
191            errors.push("[siglaUF] Campo obrigatório".to_string());
192            String::new()
193        }
194        None => {
195            errors.push("[siglaUF] Campo obrigatório".to_string());
196            String::new()
197        }
198    };
199
200    let cnpj = match raw.cnpj {
201        Some(ref v) if !v.is_empty() => v.clone(),
202        Some(_) => {
203            errors.push("[cnpj] Campo obrigatório".to_string());
204            String::new()
205        }
206        None => {
207            errors.push("[cnpj] Campo obrigatório".to_string());
208            String::new()
209        }
210    };
211
212    let schemes = match raw.schemes {
213        Some(ref v) if !v.is_empty() => v.clone(),
214        Some(_) => {
215            errors.push("[schemes] Campo obrigatório".to_string());
216            String::new()
217        }
218        None => {
219            errors.push("[schemes] Campo obrigatório".to_string());
220            String::new()
221        }
222    };
223
224    let versao = match raw.versao {
225        Some(ref v) if !v.is_empty() => v.clone(),
226        Some(_) => {
227            errors.push("[versao] Campo obrigatório".to_string());
228            String::new()
229        }
230        None => {
231            errors.push("[versao] Campo obrigatório".to_string());
232            String::new()
233        }
234    };
235
236    // Semantic validations (only if the field was provided)
237    if tp_amb != 0 && tp_amb != 1 && tp_amb != 2 {
238        errors.push(format!(
239            "[tpAmb] Valor inválido: {tp_amb}. Esperado 1 (produção) ou 2 (homologação)"
240        ));
241    }
242
243    if !sigla_uf.is_empty() && sigla_uf.len() != 2 {
244        errors.push(format!(
245            "[siglaUF] Deve ter exatamente 2 caracteres, recebido: \"{}\"",
246            sigla_uf
247        ));
248    }
249
250    if !cnpj.is_empty() {
251        let all_digits = cnpj.chars().all(|c| c.is_ascii_digit());
252        let valid_len = cnpj.len() == 11 || cnpj.len() == 14;
253        if !all_digits || !valid_len {
254            errors.push(format!(
255                "[cnpj] Deve conter 11 (CPF) ou 14 (CNPJ) dígitos, recebido: \"{}\"",
256                cnpj
257            ));
258        }
259    }
260
261    if !errors.is_empty() {
262        return Err(FiscalError::ConfigValidation(errors.join("\n")));
263    }
264
265    Ok(FiscalConfig {
266        atualizacao: raw.atualizacao,
267        tp_amb,
268        razaosocial,
269        sigla_uf,
270        cnpj,
271        schemes,
272        versao,
273        token_ibpt: raw.token_ibpt,
274        csc: raw.csc,
275        csc_id: raw.csc_id,
276        a_proxy_conf: raw.a_proxy_conf,
277    })
278}
279
280/// Custom deserializer that treats JSON `null` as `None` for `Option<String>`.
281fn deserialize_null_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
282where
283    D: serde::Deserializer<'de>,
284{
285    let opt = Option::<String>::deserialize(deserializer)?;
286    Ok(opt.filter(|s| !s.is_empty()))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn full_config_json() -> &'static str {
294        r#"{
295            "atualizacao": "2017-02-20 09:11:21",
296            "tpAmb": 2,
297            "razaosocial": "SUA RAZAO SOCIAL LTDA",
298            "siglaUF": "SP",
299            "cnpj": "93623057000128",
300            "schemes": "PL_010_V1.30",
301            "versao": "4.00",
302            "tokenIBPT": "AAAAAAA",
303            "CSC": "GPB0JBWLUR6HWFTVEAS6RJ69GPCROFPBBB8G",
304            "CSCid": "000001",
305            "aProxyConf": {
306                "proxyIp": "",
307                "proxyPort": "",
308                "proxyUser": "",
309                "proxyPass": ""
310            }
311        }"#
312    }
313
314    fn minimal_config_json() -> &'static str {
315        r#"{
316            "tpAmb": 2,
317            "razaosocial": "SUA RAZAO SOCIAL LTDA",
318            "siglaUF": "SP",
319            "cnpj": "99999999999999",
320            "schemes": "PL_009_V4",
321            "versao": "4.00"
322        }"#
323    }
324
325    #[test]
326    fn validate_full_config() {
327        let config = validate_config(full_config_json()).unwrap();
328        assert_eq!(config.tp_amb, 2);
329        assert_eq!(config.razaosocial, "SUA RAZAO SOCIAL LTDA");
330        assert_eq!(config.sigla_uf, "SP");
331        assert_eq!(config.cnpj, "93623057000128");
332        assert_eq!(config.schemes, "PL_010_V1.30");
333        assert_eq!(config.versao, "4.00");
334        assert_eq!(config.atualizacao.as_deref(), Some("2017-02-20 09:11:21"));
335        assert_eq!(config.token_ibpt.as_deref(), Some("AAAAAAA"));
336        assert_eq!(
337            config.csc.as_deref(),
338            Some("GPB0JBWLUR6HWFTVEAS6RJ69GPCROFPBBB8G")
339        );
340        assert_eq!(config.csc_id.as_deref(), Some("000001"));
341        assert!(config.a_proxy_conf.is_some());
342    }
343
344    #[test]
345    fn validate_minimal_config_without_optionals() {
346        let config = validate_config(minimal_config_json()).unwrap();
347        assert_eq!(config.tp_amb, 2);
348        assert_eq!(config.cnpj, "99999999999999");
349        assert!(config.atualizacao.is_none());
350        assert!(config.token_ibpt.is_none());
351        assert!(config.csc.is_none());
352        assert!(config.csc_id.is_none());
353        assert!(config.a_proxy_conf.is_none());
354    }
355
356    #[test]
357    fn empty_string_fails() {
358        let err = validate_config("").unwrap_err();
359        assert!(matches!(err, FiscalError::ConfigValidation(_)));
360    }
361
362    #[test]
363    fn invalid_json_fails() {
364        let err = validate_config("not json at all").unwrap_err();
365        assert!(matches!(err, FiscalError::ConfigValidation(_)));
366    }
367
368    #[test]
369    fn json_array_fails() {
370        let err = validate_config("[1,2,3]").unwrap_err();
371        assert!(matches!(err, FiscalError::ConfigValidation(_)));
372    }
373
374    #[test]
375    fn missing_tp_amb_fails() {
376        let json = r#"{
377            "razaosocial": "SUA RAZAO SOCIAL LTDA",
378            "siglaUF": "SP",
379            "cnpj": "99999999999999",
380            "schemes": "PL_009_V4",
381            "versao": "4.00"
382        }"#;
383        let err = validate_config(json).unwrap_err();
384        match &err {
385            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[tpAmb]")),
386            _ => panic!("expected ConfigValidation, got: {err:?}"),
387        }
388    }
389
390    #[test]
391    fn missing_razaosocial_fails() {
392        let json = r#"{
393            "tpAmb": 2,
394            "siglaUF": "SP",
395            "cnpj": "99999999999999",
396            "schemes": "PL_009_V4",
397            "versao": "4.00"
398        }"#;
399        let err = validate_config(json).unwrap_err();
400        match &err {
401            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[razaosocial]")),
402            _ => panic!("expected ConfigValidation, got: {err:?}"),
403        }
404    }
405
406    #[test]
407    fn missing_sigla_uf_fails() {
408        let json = r#"{
409            "tpAmb": 2,
410            "razaosocial": "SUA RAZAO SOCIAL LTDA",
411            "cnpj": "99999999999999",
412            "schemes": "PL_009_V4",
413            "versao": "4.00"
414        }"#;
415        let err = validate_config(json).unwrap_err();
416        match &err {
417            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[siglaUF]")),
418            _ => panic!("expected ConfigValidation, got: {err:?}"),
419        }
420    }
421
422    #[test]
423    fn missing_cnpj_fails() {
424        let json = r#"{
425            "tpAmb": 2,
426            "razaosocial": "SUA RAZAO SOCIAL LTDA",
427            "siglaUF": "SP",
428            "schemes": "PL_008_V4",
429            "versao": "4.00"
430        }"#;
431        let err = validate_config(json).unwrap_err();
432        match &err {
433            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[cnpj]")),
434            _ => panic!("expected ConfigValidation, got: {err:?}"),
435        }
436    }
437
438    #[test]
439    fn missing_schemes_fails() {
440        let json = r#"{
441            "tpAmb": 2,
442            "razaosocial": "SUA RAZAO SOCIAL LTDA",
443            "siglaUF": "SP",
444            "cnpj": "99999999999999",
445            "versao": "4.00"
446        }"#;
447        let err = validate_config(json).unwrap_err();
448        match &err {
449            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[schemes]")),
450            _ => panic!("expected ConfigValidation, got: {err:?}"),
451        }
452    }
453
454    #[test]
455    fn missing_versao_fails() {
456        let json = r#"{
457            "tpAmb": 2,
458            "razaosocial": "SUA RAZAO SOCIAL LTDA",
459            "siglaUF": "SP",
460            "cnpj": "99999999999999",
461            "schemes": "PL_009_V4"
462        }"#;
463        let err = validate_config(json).unwrap_err();
464        match &err {
465            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[versao]")),
466            _ => panic!("expected ConfigValidation, got: {err:?}"),
467        }
468    }
469
470    #[test]
471    fn config_with_cpf_is_valid() {
472        let json = r#"{
473            "tpAmb": 2,
474            "razaosocial": "SUA RAZAO SOCIAL LTDA",
475            "siglaUF": "SP",
476            "cnpj": "99999999999",
477            "schemes": "PL_009_V4",
478            "versao": "4.00"
479        }"#;
480        let config = validate_config(json).unwrap();
481        assert_eq!(config.cnpj, "99999999999");
482    }
483
484    #[test]
485    fn invalid_tp_amb_fails() {
486        let json = r#"{
487            "tpAmb": 3,
488            "razaosocial": "SUA RAZAO SOCIAL LTDA",
489            "siglaUF": "SP",
490            "cnpj": "99999999999999",
491            "schemes": "PL_009_V4",
492            "versao": "4.00"
493        }"#;
494        let err = validate_config(json).unwrap_err();
495        match &err {
496            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[tpAmb]")),
497            _ => panic!("expected ConfigValidation, got: {err:?}"),
498        }
499    }
500
501    #[test]
502    fn invalid_sigla_uf_length_fails() {
503        let json = r#"{
504            "tpAmb": 2,
505            "razaosocial": "SUA RAZAO SOCIAL LTDA",
506            "siglaUF": "SPP",
507            "cnpj": "99999999999999",
508            "schemes": "PL_009_V4",
509            "versao": "4.00"
510        }"#;
511        let err = validate_config(json).unwrap_err();
512        match &err {
513            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[siglaUF]")),
514            _ => panic!("expected ConfigValidation, got: {err:?}"),
515        }
516    }
517
518    #[test]
519    fn invalid_cnpj_format_fails() {
520        let json = r#"{
521            "tpAmb": 2,
522            "razaosocial": "SUA RAZAO SOCIAL LTDA",
523            "siglaUF": "SP",
524            "cnpj": "123",
525            "schemes": "PL_009_V4",
526            "versao": "4.00"
527        }"#;
528        let err = validate_config(json).unwrap_err();
529        match &err {
530            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[cnpj]")),
531            _ => panic!("expected ConfigValidation, got: {err:?}"),
532        }
533    }
534
535    #[test]
536    fn cnpj_with_non_digits_fails() {
537        let json = r#"{
538            "tpAmb": 2,
539            "razaosocial": "SUA RAZAO SOCIAL LTDA",
540            "siglaUF": "SP",
541            "cnpj": "93.623.057/0001-28",
542            "schemes": "PL_009_V4",
543            "versao": "4.00"
544        }"#;
545        let err = validate_config(json).unwrap_err();
546        match &err {
547            FiscalError::ConfigValidation(msg) => assert!(msg.contains("[cnpj]")),
548            _ => panic!("expected ConfigValidation, got: {err:?}"),
549        }
550    }
551
552    #[test]
553    fn null_optional_fields_are_accepted() {
554        let json = r#"{
555            "tpAmb": 2,
556            "razaosocial": "SUA RAZAO SOCIAL LTDA",
557            "siglaUF": "SP",
558            "cnpj": "99999999999999",
559            "schemes": "PL_009_V4",
560            "versao": "4.00",
561            "tokenIBPT": null,
562            "CSC": null,
563            "CSCid": null,
564            "aProxyConf": null
565        }"#;
566        let config = validate_config(json).unwrap();
567        assert!(config.token_ibpt.is_none());
568        assert!(config.csc.is_none());
569        assert!(config.csc_id.is_none());
570        assert!(config.a_proxy_conf.is_none());
571    }
572
573    #[test]
574    fn multiple_missing_fields_reports_all() {
575        let json = r#"{}"#;
576        let err = validate_config(json).unwrap_err();
577        match &err {
578            FiscalError::ConfigValidation(msg) => {
579                assert!(msg.contains("[tpAmb]"));
580                assert!(msg.contains("[razaosocial]"));
581                assert!(msg.contains("[siglaUF]"));
582                assert!(msg.contains("[cnpj]"));
583                assert!(msg.contains("[schemes]"));
584                assert!(msg.contains("[versao]"));
585            }
586            _ => panic!("expected ConfigValidation, got: {err:?}"),
587        }
588    }
589
590    #[test]
591    fn tp_amb_production_is_valid() {
592        let json = r#"{
593            "tpAmb": 1,
594            "razaosocial": "EMPRESA PROD",
595            "siglaUF": "MG",
596            "cnpj": "12345678901234",
597            "schemes": "PL_009_V4",
598            "versao": "4.00"
599        }"#;
600        let config = validate_config(json).unwrap();
601        assert_eq!(config.tp_amb, 1);
602    }
603
604    #[test]
605    fn proxy_config_fields_parsed() {
606        let json = r#"{
607            "tpAmb": 2,
608            "razaosocial": "EMPRESA LTDA",
609            "siglaUF": "RJ",
610            "cnpj": "12345678901234",
611            "schemes": "PL_009_V4",
612            "versao": "4.00",
613            "aProxyConf": {
614                "proxyIp": "192.168.1.1",
615                "proxyPort": "8080",
616                "proxyUser": "user",
617                "proxyPass": "pass"
618            }
619        }"#;
620        let config = validate_config(json).unwrap();
621        let proxy = config.a_proxy_conf.unwrap();
622        assert_eq!(proxy.proxy_ip.as_deref(), Some("192.168.1.1"));
623        assert_eq!(proxy.proxy_port.as_deref(), Some("8080"));
624        assert_eq!(proxy.proxy_user.as_deref(), Some("user"));
625        assert_eq!(proxy.proxy_pass.as_deref(), Some("pass"));
626    }
627}