Skip to main content

fiscal_core/xml_builder/
ide.rs

1//! Build the `<ide>` (identification) group of the NF-e XML.
2
3use super::tax_id::TaxId;
4use crate::types::{InvoiceBuildData, InvoiceModel, ReferenceDoc};
5use crate::xml_utils::{TagContent, tag};
6
7/// Format date/time for NF-e (ISO 8601 with Brazil timezone offset).
8///
9/// SEFAZ rejects UTC `"Z"` suffix — requires explicit offset like `-03:00`.
10pub fn format_datetime_nfe(dt: &chrono::DateTime<chrono::FixedOffset>, state_code: &str) -> String {
11    let offset = match state_code {
12        "AC" => "-05:00",
13        "AM" | "RO" | "RR" | "MT" | "MS" => "-04:00",
14        _ => "-03:00",
15    };
16    format!("{}{offset}", dt.format("%Y-%m-%dT%H:%M:%S"))
17}
18
19/// Build the `<ide>` element with all identification fields.
20pub(crate) fn build_ide(
21    data: &InvoiceBuildData,
22    state_ibge: &str,
23    numeric_code: &str,
24    access_key: &str,
25) -> String {
26    let ref_elements = build_references(data.references.as_deref());
27
28    let dh_emi = format_datetime_nfe(&data.issued_at, &data.issuer.state_code);
29    let series_str = data.series.to_string();
30    let number_str = data.number.to_string();
31    let tp_nf = data.operation_type.unwrap_or(1).to_string();
32    let fin_nfe = data.purpose_code.unwrap_or(1).to_string();
33
34    let mut children = vec![
35        tag("cUF", &[], TagContent::Text(state_ibge)),
36        tag("cNF", &[], TagContent::Text(numeric_code)),
37        tag("natOp", &[], TagContent::Text(&data.operation_nature)),
38        tag("mod", &[], TagContent::Text(data.model.as_str())),
39        tag("serie", &[], TagContent::Text(&series_str)),
40        tag("nNF", &[], TagContent::Text(&number_str)),
41        tag("dhEmi", &[], TagContent::Text(&dh_emi)),
42    ];
43
44    // dhSaiEnt: optional, model 55 only (matching PHP behaviour)
45    if data.model == InvoiceModel::Nfe {
46        if let Some(ref exit_dt) = data.exit_at {
47            let dh_sai_ent = format_datetime_nfe(exit_dt, &data.issuer.state_code);
48            children.push(tag("dhSaiEnt", &[], TagContent::Text(&dh_sai_ent)));
49        }
50    }
51
52    children.extend([
53        tag("tpNF", &[], TagContent::Text(&tp_nf)),
54        tag(
55            "idDest",
56            &[],
57            TagContent::Text(data.destination_indicator.as_deref().unwrap_or("1")),
58        ),
59        tag("cMunFG", &[], TagContent::Text(&data.issuer.city_code.0)),
60        tag(
61            "tpImp",
62            &[],
63            TagContent::Text(data.print_format.as_deref().unwrap_or("1")),
64        ),
65        tag("tpEmis", &[], TagContent::Text(data.emission_type.as_str())),
66        tag("cDV", &[], TagContent::Text(&access_key[43..44])),
67        tag("tpAmb", &[], TagContent::Text(data.environment.as_str())),
68        tag("finNFe", &[], TagContent::Text(&fin_nfe)),
69        tag(
70            "indFinal",
71            &[],
72            TagContent::Text(data.consumer_type.as_deref().unwrap_or("0")),
73        ),
74        tag(
75            "indPres",
76            &[],
77            TagContent::Text(data.buyer_presence.as_deref().unwrap_or("0")),
78        ),
79    ]);
80
81    // indIntermed: only emit when explicitly set (PHP uses false for required,
82    // meaning null/empty values are omitted)
83    if let Some(ref ind) = data.intermediary_indicator {
84        children.push(tag("indIntermed", &[], TagContent::Text(ind)));
85    }
86
87    children.extend([
88        tag(
89            "procEmi",
90            &[],
91            TagContent::Text(data.emission_process.as_deref().unwrap_or("0")),
92        ),
93        tag(
94            "verProc",
95            &[],
96            TagContent::Text(data.ver_proc.as_deref().unwrap_or("FinOpenPOS 1.0")),
97        ),
98    ]);
99
100    // dhCont / xJust: contingency timestamp and justification (matching PHP behaviour)
101    if let Some(ref cont) = data.contingency {
102        let dh_cont = format_datetime_nfe(&cont.at, &data.issuer.state_code);
103        children.push(tag("dhCont", &[], TagContent::Text(&dh_cont)));
104        children.push(tag("xJust", &[], TagContent::Text(&cont.reason)));
105    }
106
107    children.extend(ref_elements);
108
109    // gCompraGov and gPagAntecipado (PL_010+) go inside <ide> after NFref
110    // Only emitted when schema is PL_010 or later (matching PHP: $this->schema > 9)
111    if data.schema_version.is_pl010() {
112        if let Some(ref cg) = data.compra_gov {
113            children.push(super::optional::build_compra_gov(cg));
114        }
115        if let Some(ref pa) = data.pag_antecipado {
116            children.push(super::optional::build_pag_antecipado(pa));
117        }
118    }
119
120    tag("ide", &[], TagContent::Children(children))
121}
122
123/// Build referenced document elements (`<NFref>` inside `<ide>`).
124fn build_references(references: Option<&[ReferenceDoc]>) -> Vec<String> {
125    let Some(refs) = references else {
126        return vec![];
127    };
128
129    refs.iter()
130        .map(|r| match r {
131            ReferenceDoc::Nfe { access_key } => tag(
132                "NFref",
133                &[],
134                TagContent::Children(vec![tag("refNFe", &[], TagContent::Text(access_key))]),
135            ),
136            ReferenceDoc::NfeSig { access_key } => tag(
137                "NFref",
138                &[],
139                TagContent::Children(vec![tag("refNFeSig", &[], TagContent::Text(access_key))]),
140            ),
141            ReferenceDoc::Nf {
142                state_code,
143                year_month,
144                tax_id,
145                model,
146                series,
147                number,
148            } => tag(
149                "NFref",
150                &[],
151                TagContent::Children(vec![tag(
152                    "refNF",
153                    &[],
154                    TagContent::Children(vec![
155                        tag("cUF", &[], TagContent::Text(&state_code.0)),
156                        tag("AAMM", &[], TagContent::Text(year_month)),
157                        tag("CNPJ", &[], TagContent::Text(tax_id)),
158                        tag("mod", &[], TagContent::Text(model)),
159                        tag("serie", &[], TagContent::Text(series)),
160                        tag("nNF", &[], TagContent::Text(number)),
161                    ]),
162                )]),
163            ),
164            ReferenceDoc::Nfp {
165                state_code,
166                year_month,
167                tax_id,
168                ie,
169                model,
170                series,
171                number,
172            } => {
173                let tid = TaxId::new(tax_id);
174                tag(
175                    "NFref",
176                    &[],
177                    TagContent::Children(vec![tag(
178                        "refNFP",
179                        &[],
180                        TagContent::Children(vec![
181                            tag("cUF", &[], TagContent::Text(&state_code.0)),
182                            tag("AAMM", &[], TagContent::Text(year_month)),
183                            tag(tid.tag_name(), &[], TagContent::Text(tax_id)),
184                            tag("IE", &[], TagContent::Text(ie)),
185                            tag("mod", &[], TagContent::Text(model)),
186                            tag("serie", &[], TagContent::Text(series)),
187                            tag("nNF", &[], TagContent::Text(number)),
188                        ]),
189                    )]),
190                )
191            }
192            ReferenceDoc::Cte { access_key } => tag(
193                "NFref",
194                &[],
195                TagContent::Children(vec![tag("refCTe", &[], TagContent::Text(access_key))]),
196            ),
197            ReferenceDoc::Ecf {
198                model,
199                ecf_number,
200                coo_number,
201            } => tag(
202                "NFref",
203                &[],
204                TagContent::Children(vec![tag(
205                    "refECF",
206                    &[],
207                    TagContent::Children(vec![
208                        tag("mod", &[], TagContent::Text(model)),
209                        tag("nECF", &[], TagContent::Text(ecf_number)),
210                        tag("nCOO", &[], TagContent::Text(coo_number)),
211                    ]),
212                )]),
213            ),
214        })
215        .collect()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn build_references_nfe_emits_ref_nfe() {
224        let refs = vec![ReferenceDoc::Nfe {
225            access_key: "41260304123456000190550010000001231123456780".to_string(),
226        }];
227        let result = build_references(Some(&refs));
228        assert_eq!(result.len(), 1);
229        assert_eq!(
230            result[0],
231            "<NFref><refNFe>41260304123456000190550010000001231123456780</refNFe></NFref>"
232        );
233    }
234
235    #[test]
236    fn build_references_nfe_sig_emits_ref_nfe_sig() {
237        let refs = vec![ReferenceDoc::NfeSig {
238            access_key: "41260304123456000190550010000001231123456780".to_string(),
239        }];
240        let result = build_references(Some(&refs));
241        assert_eq!(result.len(), 1);
242        assert_eq!(
243            result[0],
244            "<NFref><refNFeSig>41260304123456000190550010000001231123456780</refNFeSig></NFref>"
245        );
246    }
247
248    #[test]
249    fn build_references_none_returns_empty() {
250        let result = build_references(None);
251        assert!(result.is_empty());
252    }
253
254    #[test]
255    fn build_references_nfe_and_nfe_sig_are_independent() {
256        let refs = vec![
257            ReferenceDoc::Nfe {
258                access_key: "11111111111111111111111111111111111111111111".to_string(),
259            },
260            ReferenceDoc::NfeSig {
261                access_key: "22222222222222222222222222222222222222222222".to_string(),
262            },
263        ];
264        let result = build_references(Some(&refs));
265        assert_eq!(result.len(), 2);
266        assert!(result[0].contains("<refNFe>"));
267        assert!(!result[0].contains("<refNFeSig>"));
268        assert!(result[1].contains("<refNFeSig>"));
269        assert!(!result[1].contains("<refNFe>"));
270    }
271}