Skip to main content

fiscal_core/xml_builder/
mod.rs

1//! NF-e/NFC-e XML builder module.
2//!
3//! Provides [`InvoiceBuilder`] — a typestate builder that enforces the
4//! invoice lifecycle at compile time:
5//!
6//! ```text
7//! InvoiceBuilder::new(issuer, env, model)   // Draft
8//!     .series(1)
9//!     .invoice_number(42)
10//!     .add_item(item)
11//!     .recipient(recipient)
12//!     .payments(vec![payment])
13//!     .build()?                              // Built
14//!     .sign_with(|xml| sign(xml))?           // Signed
15//!     .signed_xml()                          // &str
16//! ```
17
18pub mod access_key;
19mod builder;
20pub mod dest;
21pub mod det;
22pub mod emit;
23pub mod ide;
24pub mod optional;
25pub mod pag;
26pub mod tax_id;
27pub mod total;
28pub mod transp;
29
30pub use access_key::build_access_key;
31pub use builder::{Built, Draft, InvoiceBuilder, Signed};
32
33use crate::FiscalError;
34use crate::constants::{NFE_NAMESPACE, NFE_VERSION};
35use crate::newtypes::IbgeCode;
36use crate::state_codes::STATE_IBGE_CODES;
37use crate::tax_icms::{create_icms_totals, merge_icms_totals};
38use crate::types::{AccessKeyParams, InvoiceBuildData, InvoiceXmlResult};
39use crate::xml_utils::{TagContent, tag};
40
41/// Internal XML generation from a fully populated [`InvoiceBuildData`].
42///
43/// Called by [`InvoiceBuilder::build`]; not part of the public API.
44fn generate_xml(data: &InvoiceBuildData) -> Result<InvoiceXmlResult, FiscalError> {
45    let state_ibge = STATE_IBGE_CODES
46        .get(data.issuer.state_code.as_str())
47        .copied()
48        .ok_or_else(|| FiscalError::InvalidStateCode(data.issuer.state_code.clone()))?;
49
50    let numeric_code = access_key::generate_numeric_code();
51    let year_month = access_key::format_year_month(&data.issued_at);
52
53    let ak_params = AccessKeyParams {
54        state_code: IbgeCode(state_ibge.to_string()),
55        year_month,
56        tax_id: data.issuer.tax_id.clone(),
57        model: data.model,
58        series: data.series,
59        number: data.number,
60        emission_type: data.emission_type,
61        numeric_code: numeric_code.clone(),
62    };
63
64    let access_key = build_access_key(&ak_params)?;
65    let inf_nfe_id = format!("NFe{access_key}");
66
67    // Build items and accumulate tax totals
68    let mut icms_totals = create_icms_totals();
69    let mut total_products: i64 = 0;
70    let mut total_ipi: i64 = 0;
71    let mut total_pis: i64 = 0;
72    let mut total_cofins: i64 = 0;
73    let mut total_ii: i64 = 0;
74    let mut total_frete: i64 = 0;
75    let mut total_seg: i64 = 0;
76    let mut total_desc: i64 = 0;
77    let mut total_outro: i64 = 0;
78    let mut total_tot_trib: i64 = 0;
79    let mut total_ipi_devol: i64 = 0;
80    let mut total_pis_st: i64 = 0;
81    let mut total_cofins_st: i64 = 0;
82    let mut any_ind_deduz_deson = false;
83
84    let mut det_elements = Vec::with_capacity(data.items.len());
85    for item in &data.items {
86        let det_result = det::build_det(item, data)?;
87        // Track ind_deduz_deson from any item
88        if det_result.ind_deduz_deson {
89            any_ind_deduz_deson = true;
90        }
91        // Only accumulate into totals when indTot == 1 (the default)
92        if det_result.ind_tot == 1 {
93            total_products += item.total_price.0;
94            total_ipi += det_result.v_ipi;
95            total_pis += det_result.v_pis;
96            total_cofins += det_result.v_cofins;
97            total_ii += det_result.v_ii;
98            total_frete += det_result.v_frete;
99            total_seg += det_result.v_seg;
100            total_desc += det_result.v_desc;
101            total_outro += det_result.v_outro;
102            total_tot_trib += det_result.v_tot_trib;
103            total_ipi_devol += det_result.v_ipi_devol;
104            total_pis_st += det_result.v_pis_st;
105            total_cofins_st += det_result.v_cofins_st;
106            merge_icms_totals(&mut icms_totals, &det_result.icms_totals);
107        }
108        det_elements.push(det_result.xml);
109    }
110
111    // Assemble infNFe children in schema order
112    let mut inf_children = vec![
113        ide::build_ide(data, state_ibge, &numeric_code, &access_key),
114        emit::build_emit(data),
115    ];
116
117    if let Some(dest_xml) = dest::build_dest(data) {
118        inf_children.push(dest_xml);
119    }
120
121    if let Some(ref w) = data.withdrawal {
122        inf_children.push(optional::build_withdrawal(w));
123    }
124    if let Some(ref d) = data.delivery {
125        inf_children.push(optional::build_delivery(d));
126    }
127    if let Some(ref auths) = data.authorized_xml {
128        for a in auths {
129            inf_children.push(optional::build_aut_xml(a));
130        }
131    }
132
133    inf_children.extend(det_elements);
134
135    // Propagate item-level indDeduzDeson to ICMS totals
136    if any_ind_deduz_deson {
137        icms_totals.ind_deduz_deson = true;
138    }
139
140    inf_children.push(total::build_total(
141        total_products,
142        &icms_totals,
143        &total::OtherTotals {
144            v_ipi: total_ipi,
145            v_pis: total_pis,
146            v_cofins: total_cofins,
147            v_ii: total_ii,
148            v_frete: total_frete,
149            v_seg: total_seg,
150            v_desc: total_desc,
151            v_outro: total_outro,
152            v_tot_trib: total_tot_trib,
153            v_ipi_devol: total_ipi_devol,
154            v_pis_st: total_pis_st,
155            v_cofins_st: total_cofins_st,
156        },
157        data.ret_trib.as_ref(),
158        data.issqn_tot.as_ref(),
159        data.is_tot.as_ref(),
160        data.ibs_cbs_tot.as_ref(),
161        data.schema_version,
162        data.calculation_method,
163        data.v_nf_tot_override,
164    ));
165
166    inf_children.push(transp::build_transp(data));
167
168    if let Some(ref billing) = data.billing {
169        inf_children.push(optional::build_cobr(billing));
170    }
171
172    inf_children.push(pag::build_pag(
173        &data.payments,
174        data.change_amount,
175        data.payment_card_details.as_deref(),
176    ));
177
178    if let Some(ref intermed) = data.intermediary {
179        inf_children.push(optional::build_intermediary(intermed));
180    }
181
182    let inf_adic = optional::build_inf_adic(data);
183    if !inf_adic.is_empty() {
184        inf_children.push(inf_adic);
185    }
186
187    if let Some(ref exp) = data.export {
188        inf_children.push(optional::build_export(exp));
189    }
190    if let Some(ref purchase) = data.purchase {
191        inf_children.push(optional::build_purchase(purchase));
192    }
193    if let Some(ref cana) = data.cana {
194        inf_children.push(optional::build_cana(cana));
195    }
196    if let Some(ref tech) = data.tech_responsible {
197        inf_children.push(optional::build_tech_responsible_with_key(tech, &access_key));
198    }
199    // agropecuario — only emitted when schema is PL_010 or later
200    // (matching PHP: if ($this->schema < 10) { return; })
201    if data.schema_version.is_pl010() {
202        if let Some(ref agro) = data.agropecuario {
203            inf_children.push(optional::build_agropecuario(agro));
204        }
205    }
206
207    // Matches PHP sped-nfe: no xmlns on infNFe (inherited from NFe parent),
208    // Id before versao (same order as PHP's DOMDocument setAttribute calls)
209    let inf_nfe = tag(
210        "infNFe",
211        &[("Id", &inf_nfe_id), ("versao", NFE_VERSION)],
212        TagContent::Children(inf_children),
213    );
214
215    let mut xml = format!(
216        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}",
217        tag(
218            "NFe",
219            &[("xmlns", NFE_NAMESPACE)],
220            TagContent::Children(vec![inf_nfe])
221        ),
222    );
223
224    if data.only_ascii {
225        xml = crate::sanitize::sanitize_xml_text(&xml);
226    }
227
228    Ok(InvoiceXmlResult { xml, access_key })
229}