Skip to main content

fiscal_core/xml_builder/det/
mod.rs

1//! Build `<det>` (item detail) elements of the NF-e XML.
2
3mod icms_variant;
4mod prod;
5
6#[cfg(test)]
7mod tests;
8
9use crate::FiscalError;
10use crate::format_utils::{format_cents, format_decimal, format_rate4};
11use crate::newtypes::{Cents, Rate4};
12use crate::tax_ibs_cbs;
13use crate::tax_icms::{self, IcmsTotals};
14use crate::tax_is;
15use crate::tax_issqn;
16use crate::tax_pis_cofins_ipi::{self, CofinsData, IiData, IpiData, PisData};
17use crate::types::{InvoiceBuildData, InvoiceItemData, InvoiceModel, SefazEnvironment, TaxRegime};
18use crate::xml_utils::{TagContent, tag};
19
20use icms_variant::build_icms_variant;
21#[cfg(test)]
22use prod::build_comb_xml;
23use prod::{build_det_export_xml, build_det_extras, build_di_xml, build_prod_options};
24
25/// Constant used when emitting NFC-e in homologation environment (first item only).
26const HOMOLOGATION_XPROD: &str =
27    "NOTA FISCAL EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL";
28
29/// Result from building a single `<det>` element.
30#[derive(Debug, Clone)]
31pub struct DetResult {
32    /// The serialised `<det>` XML string.
33    pub xml: String,
34    /// Accumulated ICMS totals contributed by this item.
35    pub icms_totals: IcmsTotals,
36    /// IPI value in cents contributed by this item.
37    pub v_ipi: i64,
38    /// PIS value in cents contributed by this item.
39    pub v_pis: i64,
40    /// COFINS value in cents contributed by this item.
41    pub v_cofins: i64,
42    /// II (import tax) value in cents contributed by this item.
43    pub v_ii: i64,
44    /// Freight value in cents for this item.
45    pub v_frete: i64,
46    /// Insurance value in cents for this item.
47    pub v_seg: i64,
48    /// Discount value in cents for this item.
49    pub v_desc: i64,
50    /// Other expenses value in cents for this item.
51    pub v_outro: i64,
52    /// Whether this item counts towards the invoice total (indTot).
53    pub ind_tot: u8,
54    /// Approximate total tax for this item (`vTotTrib`). Optional.
55    pub v_tot_trib: i64,
56    /// IPI devolution value in cents contributed by this item.
57    pub v_ipi_devol: i64,
58    /// PIS-ST value in cents contributed by this item (only when indSomaPISST = 1).
59    pub v_pis_st: i64,
60    /// COFINS-ST value in cents contributed by this item (only when indSomaCOFINSST = 1).
61    pub v_cofins_st: i64,
62    /// Whether this item has indDeduzDeson = 1 (desoneration deduction applies).
63    pub ind_deduz_deson: bool,
64    /// Whether this item uses ISSQN instead of ICMS.
65    pub has_issqn: bool,
66}
67
68/// Build a `<det nItem="N">` element for one invoice item.
69pub(crate) fn build_det(
70    item: &InvoiceItemData,
71    data: &InvoiceBuildData,
72) -> Result<DetResult, FiscalError> {
73    // Validate NVE: up to 8 per item
74    if item.nve.len() > 8 {
75        return Err(FiscalError::InvalidTaxData(format!(
76            "Item {}: NVE limited to 8 entries, got {}",
77            item.item_number,
78            item.nve.len()
79        )));
80    }
81
82    let is_simples = matches!(
83        data.issuer.tax_regime,
84        TaxRegime::SimplesNacional | TaxRegime::SimplesExcess
85    );
86
87    let has_issqn = item.issqn.is_some();
88
89    // Build ICMS (skipped when item has ISSQN)
90    let mut icms_totals = IcmsTotals::default();
91    let icms_xml = if has_issqn {
92        String::new()
93    } else {
94        let icms_variant = build_icms_variant(item, is_simples)?;
95        tax_icms::build_icms_xml(&icms_variant, &mut icms_totals)?
96    };
97
98    // Build ISSQN (optional — only when item.issqn is set)
99    let issqn_xml = if let Some(ref issqn_data) = item.issqn {
100        tax_issqn::build_issqn_xml(issqn_data)
101    } else {
102        String::new()
103    };
104
105    // Build PIS
106    let pis_xml = tax_pis_cofins_ipi::build_pis_xml(&PisData {
107        cst: item.pis_cst.clone(),
108        v_bc: item.pis_v_bc.or(Some(Cents(0))),
109        p_pis: item.pis_p_pis.or(Some(Rate4(0))),
110        v_pis: item.pis_v_pis.or(Some(Cents(0))),
111        q_bc_prod: item.pis_q_bc_prod,
112        v_aliq_prod: item.pis_v_aliq_prod,
113    });
114
115    // Build COFINS
116    let cofins_xml = tax_pis_cofins_ipi::build_cofins_xml(&CofinsData {
117        cst: item.cofins_cst.clone(),
118        v_bc: item.cofins_v_bc.or(Some(Cents(0))),
119        p_cofins: item.cofins_p_cofins.or(Some(Rate4(0))),
120        v_cofins: item.cofins_v_cofins.or(Some(Cents(0))),
121        q_bc_prod: item.cofins_q_bc_prod,
122        v_aliq_prod: item.cofins_v_aliq_prod,
123    });
124
125    // Build IPI (optional)
126    let mut ipi_xml = String::new();
127    let mut v_ipi = 0i64;
128    if let Some(ref ipi_cst) = item.ipi_cst {
129        ipi_xml = tax_pis_cofins_ipi::build_ipi_xml(&IpiData {
130            cst: ipi_cst.clone(),
131            c_enq: item.ipi_c_enq.clone().unwrap_or_else(|| "999".to_string()),
132            v_bc: item.ipi_v_bc,
133            p_ipi: item.ipi_p_ipi,
134            v_ipi: item.ipi_v_ipi,
135            q_unid: item.ipi_q_unid,
136            v_unid: item.ipi_v_unid,
137            ..IpiData::default()
138        });
139        v_ipi = item.ipi_v_ipi.map(|c| c.0).unwrap_or(0);
140    }
141
142    // Build II (optional)
143    let mut ii_xml = String::new();
144    let mut v_ii = 0i64;
145    if let Some(ii_vbc) = item.ii_v_bc {
146        ii_xml = tax_pis_cofins_ipi::build_ii_xml(&IiData {
147            v_bc: ii_vbc,
148            v_desp_adu: item.ii_v_desp_adu.unwrap_or(Cents(0)),
149            v_ii: item.ii_v_ii.unwrap_or(Cents(0)),
150            v_iof: item.ii_v_iof.unwrap_or(Cents(0)),
151        });
152        v_ii = item.ii_v_ii.map(|c| c.0).unwrap_or(0);
153    }
154
155    // Build prod options (rastro, veicProd, med, arma, comb, nRECOPI)
156    let prod_options = build_prod_options(item);
157
158    // Build PIS-ST (optional)
159    let mut v_pis_st = 0i64;
160    if let Some(ref pis_st_data) = item.pis_st {
161        // Accumulate only when indSomaPISST == 1 (matches PHP)
162        if pis_st_data.ind_soma_pis_st == Some(1) {
163            v_pis_st = pis_st_data.v_pis.0;
164        }
165    }
166
167    // Build COFINS-ST (optional)
168    let mut v_cofins_st = 0i64;
169    if let Some(ref cofins_st_data) = item.cofins_st {
170        // Accumulate only when indSomaCOFINSST == 1 (matches PHP)
171        if cofins_st_data.ind_soma_cofins_st == Some(1) {
172            v_cofins_st = cofins_st_data.v_cofins.0;
173        }
174    }
175
176    // Detect indDeduzDeson from item ICMS data
177    let item_ind_deduz_deson = item
178        .icms_ind_deduz_deson
179        .as_deref()
180        .map(|v| v == "1")
181        .unwrap_or(false);
182
183    // Build det-level extras (infAdProd, obsItem, vItem, DFeReferenciado)
184    // Deferred to after imposto assembly so we have access to computed values.
185
186    // Assemble imposto
187    let mut imposto_children: Vec<String> = Vec::new();
188    // vTotTrib: emitted as first child of <imposto> when > 0 (matches PHP tagimposto)
189    if let Some(ref v) = item.v_tot_trib {
190        if v.0 > 0 {
191            imposto_children.push(tag(
192                "vTotTrib",
193                &[],
194                TagContent::Text(&format_cents(v.0, 2)),
195            ));
196        }
197    }
198    if !icms_xml.is_empty() {
199        imposto_children.push(icms_xml);
200    }
201    if !ipi_xml.is_empty() {
202        imposto_children.push(ipi_xml);
203    }
204    // PIS or PISST (mutually exclusive per PHP sped-nfe)
205    if let Some(ref pis_st) = item.pis_st {
206        imposto_children.push(tax_pis_cofins_ipi::build_pis_st_xml(pis_st));
207    } else {
208        imposto_children.push(pis_xml);
209    }
210    // COFINS or COFINSST (mutually exclusive per PHP sped-nfe)
211    if let Some(ref cofins_st) = item.cofins_st {
212        imposto_children.push(tax_pis_cofins_ipi::build_cofins_st_xml(cofins_st));
213    } else {
214        imposto_children.push(cofins_xml);
215    }
216    if !ii_xml.is_empty() {
217        imposto_children.push(ii_xml);
218    }
219    if !issqn_xml.is_empty() {
220        imposto_children.push(issqn_xml);
221    }
222
223    // Build IS (Imposto Seletivo) -- optional, inside <imposto>
224    // Only emitted when schema is PL_010 or later (matching PHP: $this->schema > 9)
225    if data.schema_version.is_pl010() {
226        if let Some(ref is_data) = item.is_data {
227            imposto_children.push(tax_is::build_is_xml(is_data));
228        }
229
230        // Build IBS/CBS -- optional, inside <imposto>
231        if let Some(ref ibs_cbs_data) = item.ibs_cbs {
232            imposto_children.push(tax_ibs_cbs::build_ibs_cbs_xml(ibs_cbs_data));
233        }
234    }
235
236    // Assemble prod
237    let fc2 = |c: i64| format_cents(c, 2);
238    let fc10 = |c: i64| format_cents(c, 10);
239    let fd4 = |v: f64| format_decimal(v, 4);
240
241    let mut prod_children = vec![
242        tag("cProd", &[], TagContent::Text(&item.product_code)),
243        tag(
244            "cEAN",
245            &[],
246            TagContent::Text(item.c_ean.as_deref().unwrap_or("SEM GTIN")),
247        ),
248    ];
249    if let Some(ref cb) = item.c_barra {
250        prod_children.push(tag("cBarra", &[], TagContent::Text(cb)));
251    }
252    prod_children.push(tag(
253        "xProd",
254        &[],
255        TagContent::Text(
256            // PHP substitutes xProd for item 1 of NFC-e in homologation
257            if item.item_number == 1
258                && data.environment == SefazEnvironment::Homologation
259                && data.model == InvoiceModel::Nfce
260            {
261                HOMOLOGATION_XPROD
262            } else {
263                &item.description
264            },
265        ),
266    ));
267    prod_children.push(tag("NCM", &[], TagContent::Text(&item.ncm)));
268    for nve_code in &item.nve {
269        prod_children.push(tag("NVE", &[], TagContent::Text(nve_code)));
270    }
271    if let Some(ref cest) = item.cest {
272        prod_children.push(tag("CEST", &[], TagContent::Text(cest)));
273        if let Some(ref ind) = item.cest_ind_escala {
274            prod_children.push(tag("indEscala", &[], TagContent::Text(ind)));
275        }
276        if let Some(ref fab) = item.cest_cnpj_fab {
277            prod_children.push(tag("CNPJFab", &[], TagContent::Text(fab)));
278        }
279    }
280    if let Some(ref cb) = item.c_benef {
281        prod_children.push(tag("cBenef", &[], TagContent::Text(cb)));
282    }
283    // tpCredPresIBSZFM — PL_010 only, after cBenef, before gCred (NT 2025.002)
284    if data.schema_version.is_pl010() {
285        if let Some(ref tp) = item.tp_cred_pres_ibs_zfm {
286            prod_children.push(tag("tpCredPresIBSZFM", &[], TagContent::Text(tp)));
287        }
288    }
289    // gCred (crédito presumido ICMS) — up to 4 per item, inside <prod>
290    for gc in item.g_cred.iter().take(4) {
291        let p_str = format_rate4(gc.p_cred_presumido.0);
292        let mut gc_children = vec![
293            tag(
294                "cCredPresumido",
295                &[],
296                TagContent::Text(&gc.c_cred_presumido),
297            ),
298            tag("pCredPresumido", &[], TagContent::Text(&p_str)),
299        ];
300        if let Some(v) = gc.v_cred_presumido {
301            let v_str = format_cents(v.0, 2);
302            gc_children.push(tag("vCredPresumido", &[], TagContent::Text(&v_str)));
303        }
304        prod_children.push(tag("gCred", &[], TagContent::Children(gc_children)));
305    }
306    if let Some(ref ex) = item.extipi {
307        prod_children.push(tag("EXTIPI", &[], TagContent::Text(ex)));
308    }
309    prod_children.extend([
310        tag("CFOP", &[], TagContent::Text(&item.cfop)),
311        tag("uCom", &[], TagContent::Text(&item.unit_of_measure)),
312        tag("qCom", &[], TagContent::Text(&fd4(item.quantity))),
313        tag("vUnCom", &[], TagContent::Text(&fc10(item.unit_price.0))),
314        tag("vProd", &[], TagContent::Text(&fc2(item.total_price.0))),
315        tag(
316            "cEANTrib",
317            &[],
318            TagContent::Text(item.c_ean_trib.as_deref().unwrap_or("SEM GTIN")),
319        ),
320    ]);
321    if let Some(ref cbt) = item.c_barra_trib {
322        prod_children.push(tag("cBarraTrib", &[], TagContent::Text(cbt)));
323    }
324    let u_trib = item
325        .taxable_unit
326        .as_deref()
327        .unwrap_or(&item.unit_of_measure);
328    let q_trib = item.taxable_quantity.unwrap_or(item.quantity);
329    let v_un_trib = item
330        .taxable_unit_price
331        .map(|c| c.0)
332        .unwrap_or(item.unit_price.0);
333    prod_children.extend([
334        tag("uTrib", &[], TagContent::Text(u_trib)),
335        tag("qTrib", &[], TagContent::Text(&fd4(q_trib))),
336        tag("vUnTrib", &[], TagContent::Text(&fc10(v_un_trib))),
337    ]);
338    if let Some(v) = item.v_frete {
339        prod_children.push(tag("vFrete", &[], TagContent::Text(&fc2(v.0))));
340    }
341    if let Some(v) = item.v_seg {
342        prod_children.push(tag("vSeg", &[], TagContent::Text(&fc2(v.0))));
343    }
344    if let Some(v) = item.v_desc {
345        prod_children.push(tag("vDesc", &[], TagContent::Text(&fc2(v.0))));
346    }
347    if let Some(v) = item.v_outro {
348        prod_children.push(tag("vOutro", &[], TagContent::Text(&fc2(v.0))));
349    }
350    let ind_tot_str = match item.ind_tot {
351        Some(v) => v.to_string(),
352        None => "1".to_string(),
353    };
354    prod_children.push(tag("indTot", &[], TagContent::Text(&ind_tot_str)));
355    if item.ind_bem_movel_usado == Some(true) {
356        prod_children.push(tag("indBemMovelUsado", &[], TagContent::Text("1")));
357    }
358    // DI (Declaração de Importação) — after indTot, before detExport
359    if let Some(ref dis) = item.di {
360        for di in dis.iter().take(100) {
361            prod_children.push(build_di_xml(di));
362        }
363    }
364    // detExport — after DI, before xPed
365    if let Some(ref exports) = item.det_export {
366        for dex in exports.iter().take(500) {
367            prod_children.push(build_det_export_xml(dex));
368        }
369    }
370    if let Some(ref xped) = item.x_ped {
371        prod_children.push(tag("xPed", &[], TagContent::Text(xped)));
372    }
373    if let Some(ref nip) = item.n_item_ped {
374        prod_children.push(tag("nItemPed", &[], TagContent::Text(nip)));
375    }
376    if let Some(ref nfci) = item.n_fci {
377        prod_children.push(tag("nFCI", &[], TagContent::Text(nfci)));
378    }
379    prod_children.extend(prod_options);
380
381    // impostoDevol (after imposto, before infAdProd)
382    let mut v_ipi_devol = 0i64;
383    let imposto_devol_xml = if let Some(ref devol) = item.imposto_devol {
384        v_ipi_devol = devol.v_ipi_devol.0;
385        let p_devol_str = format_decimal(devol.p_devol.0 as f64 / 100.0, 2);
386        let v_ipi_devol_str = format_cents(devol.v_ipi_devol.0, 2);
387        tag(
388            "impostoDevol",
389            &[],
390            TagContent::Children(vec![
391                tag("pDevol", &[], TagContent::Text(&p_devol_str)),
392                tag(
393                    "IPI",
394                    &[],
395                    TagContent::Children(vec![tag(
396                        "vIPIDevol",
397                        &[],
398                        TagContent::Text(&v_ipi_devol_str),
399                    )]),
400                ),
401            ]),
402        )
403    } else {
404        String::new()
405    };
406
407    // Compute vItem for PL_010 — matching PHP calculateTtensValues2
408    // Emitted inside <det> when schema >= PL_010 and at least one item has IBS/CBS data.
409    let v_item_xml =
410        if data.schema_version.is_pl010() && data.items.iter().any(|i| i.ibs_cbs.is_some()) {
411            let v_item_cents = if let Some(ref explicit) = item.v_item {
412                // User-supplied vItem takes precedence (matches PHP: $this->aVItem[$item]['vItem'])
413                explicit.0
414            } else {
415                // Auto-calculate (matches PHP calculateTtensValues2)
416                let v_prod = item.total_price.0;
417                let v_desc = item.v_desc.map(|c| c.0).unwrap_or(0);
418                let v_icms_deson = if item_ind_deduz_deson {
419                    item.icms_v_icms_deson.map(|c| c.0).unwrap_or(0)
420                } else {
421                    0
422                };
423                let v_icms_st = icms_totals.v_st.0;
424                let v_icms_mono_reten = icms_totals.v_icms_mono_reten.0;
425                let v_fcp_st = icms_totals.v_fcp_st.0;
426                let v_frete = item.v_frete.map(|c| c.0).unwrap_or(0);
427                let v_seg = item.v_seg.map(|c| c.0).unwrap_or(0);
428                let v_outro = item.v_outro.map(|c| c.0).unwrap_or(0);
429
430                v_prod - v_desc - v_icms_deson
431                    + v_icms_st
432                    + v_icms_mono_reten
433                    + v_fcp_st
434                    + v_frete
435                    + v_seg
436                    + v_outro
437                    + v_ii
438                    + v_ipi
439                    + v_ipi_devol
440                    + v_pis_st
441                    + v_cofins_st
442            };
443            let v_item_str = format_cents(v_item_cents, 2);
444            tag("vItem", &[], TagContent::Text(&v_item_str))
445        } else {
446            String::new()
447        };
448
449    // Build det-level extras (infAdProd, obsItem, vItem, DFeReferenciado)
450    let det_extras = build_det_extras(item, &v_item_xml);
451
452    // Assemble det
453    let nitem = item.item_number.to_string();
454    let mut det_children = vec![
455        tag("prod", &[], TagContent::Children(prod_children)),
456        tag("imposto", &[], TagContent::Children(imposto_children)),
457    ];
458    if !imposto_devol_xml.is_empty() {
459        det_children.push(imposto_devol_xml);
460    }
461    det_children.extend(det_extras);
462
463    let xml = tag(
464        "det",
465        &[("nItem", &nitem)],
466        TagContent::Children(det_children),
467    );
468
469    Ok(DetResult {
470        xml,
471        icms_totals,
472        v_ipi,
473        v_pis: item.pis_v_pis.map(|c| c.0).unwrap_or(0),
474        v_cofins: item.cofins_v_cofins.map(|c| c.0).unwrap_or(0),
475        v_ii,
476        v_frete: item.v_frete.map(|c| c.0).unwrap_or(0),
477        v_seg: item.v_seg.map(|c| c.0).unwrap_or(0),
478        v_desc: item.v_desc.map(|c| c.0).unwrap_or(0),
479        v_outro: item.v_outro.map(|c| c.0).unwrap_or(0),
480        ind_tot: item.ind_tot.unwrap_or(1),
481        v_tot_trib: item.v_tot_trib.map(|c| c.0).unwrap_or(0),
482        v_ipi_devol,
483        v_pis_st,
484        v_cofins_st,
485        ind_deduz_deson: item_ind_deduz_deson,
486        has_issqn,
487    })
488}