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