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