Skip to main content

fiscal_core/xml_builder/
det.rs

1//! Build `<det>` (item detail) elements of the NF-e XML.
2
3use crate::FiscalError;
4use crate::format_utils::{format_cents, format_decimal};
5use crate::newtypes::{Cents, Rate, Rate4};
6use crate::tax_ibs_cbs;
7use crate::tax_icms::{self, IcmsCsosn, IcmsCst, IcmsTotals, IcmsVariant};
8use crate::tax_is;
9use crate::tax_issqn;
10use crate::tax_pis_cofins_ipi::{self, CofinsData, IiData, IpiData, PisData};
11use crate::types::{
12    CombData, InvoiceBuildData, InvoiceItemData, InvoiceModel, SefazEnvironment, TaxRegime,
13};
14use crate::xml_utils::{TagContent, tag};
15
16/// Constant used when emitting NFC-e in homologation environment (first item only).
17const HOMOLOGATION_XPROD: &str =
18    "NOTA FISCAL EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL";
19
20/// Result from building a single `<det>` element.
21#[derive(Debug, Clone)]
22pub struct DetResult {
23    /// The serialised `<det>` XML string.
24    pub xml: String,
25    /// Accumulated ICMS totals contributed by this item.
26    pub icms_totals: IcmsTotals,
27    /// IPI value in cents contributed by this item.
28    pub v_ipi: i64,
29    /// PIS value in cents contributed by this item.
30    pub v_pis: i64,
31    /// COFINS value in cents contributed by this item.
32    pub v_cofins: i64,
33    /// II (import tax) value in cents contributed by this item.
34    pub v_ii: i64,
35    /// Freight value in cents for this item.
36    pub v_frete: i64,
37    /// Insurance value in cents for this item.
38    pub v_seg: i64,
39    /// Discount value in cents for this item.
40    pub v_desc: i64,
41    /// Other expenses value in cents for this item.
42    pub v_outro: i64,
43    /// Whether this item counts towards the invoice total (indTot).
44    pub ind_tot: u8,
45    /// Approximate total tax for this item (`vTotTrib`). Optional.
46    pub v_tot_trib: i64,
47    /// IPI devolution value in cents contributed by this item.
48    pub v_ipi_devol: i64,
49    /// Whether this item uses ISSQN instead of ICMS.
50    pub has_issqn: bool,
51}
52
53/// Map an invoice item's ICMS fields to the correct typed [`IcmsVariant`].
54fn build_icms_variant(
55    item: &InvoiceItemData,
56    is_simples: bool,
57) -> Result<IcmsVariant, FiscalError> {
58    let orig = item.orig.clone().unwrap_or_else(|| "0".to_string());
59
60    if is_simples {
61        let csosn_code = if item.icms_cst.is_empty() {
62            "102"
63        } else {
64            item.icms_cst.as_str()
65        };
66
67        let csosn = match csosn_code {
68            "101" => IcmsCsosn::Csosn101 {
69                orig,
70                csosn: csosn_code.to_string(),
71                p_cred_sn: item.icms_p_cred_sn.ok_or_else(|| {
72                    FiscalError::MissingRequiredField {
73                        field: "pCredSN".to_string(),
74                    }
75                })?,
76                v_cred_icms_sn: item.icms_v_cred_icms_sn.ok_or_else(|| {
77                    FiscalError::MissingRequiredField {
78                        field: "vCredICMSSN".to_string(),
79                    }
80                })?,
81            },
82            "102" | "103" | "300" | "400" => IcmsCsosn::Csosn102 {
83                orig,
84                csosn: csosn_code.to_string(),
85            },
86            "201" => IcmsCsosn::Csosn201 {
87                orig,
88                csosn: csosn_code.to_string(),
89                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
90                    FiscalError::MissingRequiredField {
91                        field: "modBCST".to_string(),
92                    }
93                })?,
94                p_mva_st: item.icms_p_mva_st,
95                p_red_bc_st: item.icms_red_bc_st,
96                v_bc_st: item
97                    .icms_v_bc_st
98                    .ok_or_else(|| FiscalError::MissingRequiredField {
99                        field: "vBCST".to_string(),
100                    })?,
101                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
102                    FiscalError::MissingRequiredField {
103                        field: "pICMSST".to_string(),
104                    }
105                })?,
106                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
107                    FiscalError::MissingRequiredField {
108                        field: "vICMSST".to_string(),
109                    }
110                })?,
111                v_bc_fcp_st: item.icms_v_bc_fcp_st,
112                p_fcp_st: item.icms_p_fcp_st,
113                v_fcp_st: item.icms_v_fcp_st,
114                p_cred_sn: item.icms_p_cred_sn,
115                v_cred_icms_sn: item.icms_v_cred_icms_sn,
116            },
117            "202" | "203" => IcmsCsosn::Csosn202 {
118                orig,
119                csosn: csosn_code.to_string(),
120                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
121                    FiscalError::MissingRequiredField {
122                        field: "modBCST".to_string(),
123                    }
124                })?,
125                p_mva_st: item.icms_p_mva_st,
126                p_red_bc_st: item.icms_red_bc_st,
127                v_bc_st: item
128                    .icms_v_bc_st
129                    .ok_or_else(|| FiscalError::MissingRequiredField {
130                        field: "vBCST".to_string(),
131                    })?,
132                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
133                    FiscalError::MissingRequiredField {
134                        field: "pICMSST".to_string(),
135                    }
136                })?,
137                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
138                    FiscalError::MissingRequiredField {
139                        field: "vICMSST".to_string(),
140                    }
141                })?,
142                v_bc_fcp_st: item.icms_v_bc_fcp_st,
143                p_fcp_st: item.icms_p_fcp_st,
144                v_fcp_st: item.icms_v_fcp_st,
145            },
146            "500" => IcmsCsosn::Csosn500 {
147                orig,
148                csosn: csosn_code.to_string(),
149                v_bc_st_ret: None,
150                p_st: None,
151                v_icms_substituto: item.icms_v_icms_substituto,
152                v_icms_st_ret: None,
153                v_bc_fcp_st_ret: None,
154                p_fcp_st_ret: None,
155                v_fcp_st_ret: None,
156                p_red_bc_efet: None,
157                v_bc_efet: None,
158                p_icms_efet: None,
159                v_icms_efet: None,
160            },
161            "900" => IcmsCsosn::Csosn900 {
162                orig,
163                csosn: csosn_code.to_string(),
164                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
165                v_bc: Some(item.total_price),
166                p_red_bc: item.icms_red_bc,
167                p_icms: Some(item.icms_rate),
168                v_icms: Some(item.icms_amount),
169                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()),
170                p_mva_st: item.icms_p_mva_st,
171                p_red_bc_st: item.icms_red_bc_st,
172                v_bc_st: item.icms_v_bc_st,
173                p_icms_st: item.icms_p_icms_st,
174                v_icms_st: item.icms_v_icms_st,
175                v_bc_fcp_st: item.icms_v_bc_fcp_st,
176                p_fcp_st: item.icms_p_fcp_st,
177                v_fcp_st: item.icms_v_fcp_st,
178                p_cred_sn: item.icms_p_cred_sn,
179                v_cred_icms_sn: item.icms_v_cred_icms_sn,
180            },
181            other => return Err(FiscalError::UnsupportedIcmsCsosn(other.to_string())),
182        };
183        Ok(csosn.into())
184    } else {
185        let cst_code = item.icms_cst.as_str();
186        let cst = match cst_code {
187            "00" => IcmsCst::Cst00 {
188                orig,
189                mod_bc: item
190                    .icms_mod_bc
191                    .map(|v| v.to_string())
192                    .unwrap_or_else(|| "3".to_string()),
193                v_bc: item.total_price,
194                p_icms: item.icms_rate,
195                v_icms: item.icms_amount,
196                p_fcp: item.icms_p_fcp,
197                v_fcp: item.icms_v_fcp,
198            },
199            "10" => IcmsCst::Cst10 {
200                orig,
201                mod_bc: item
202                    .icms_mod_bc
203                    .map(|v| v.to_string())
204                    .unwrap_or_else(|| "3".to_string()),
205                v_bc: item.total_price,
206                p_icms: item.icms_rate,
207                v_icms: item.icms_amount,
208                v_bc_fcp: item.icms_v_bc_fcp,
209                p_fcp: item.icms_p_fcp,
210                v_fcp: item.icms_v_fcp,
211                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
212                    FiscalError::MissingRequiredField {
213                        field: "modBCST".to_string(),
214                    }
215                })?,
216                p_mva_st: item.icms_p_mva_st,
217                p_red_bc_st: item.icms_red_bc_st,
218                v_bc_st: item
219                    .icms_v_bc_st
220                    .ok_or_else(|| FiscalError::MissingRequiredField {
221                        field: "vBCST".to_string(),
222                    })?,
223                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
224                    FiscalError::MissingRequiredField {
225                        field: "pICMSST".to_string(),
226                    }
227                })?,
228                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
229                    FiscalError::MissingRequiredField {
230                        field: "vICMSST".to_string(),
231                    }
232                })?,
233                v_bc_fcp_st: item.icms_v_bc_fcp_st,
234                p_fcp_st: item.icms_p_fcp_st,
235                v_fcp_st: item.icms_v_fcp_st,
236                v_icms_st_deson: None,
237                mot_des_icms_st: None,
238            },
239            "20" => IcmsCst::Cst20 {
240                orig,
241                mod_bc: item
242                    .icms_mod_bc
243                    .map(|v| v.to_string())
244                    .unwrap_or_else(|| "3".to_string()),
245                p_red_bc: item.icms_red_bc.unwrap_or(Rate(0)),
246                v_bc: item.total_price,
247                p_icms: item.icms_rate,
248                v_icms: item.icms_amount,
249                v_bc_fcp: item.icms_v_bc_fcp,
250                p_fcp: item.icms_p_fcp,
251                v_fcp: item.icms_v_fcp,
252                v_icms_deson: item.icms_v_icms_deson,
253                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
254                ind_deduz_deson: None,
255            },
256            "30" => IcmsCst::Cst30 {
257                orig,
258                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
259                    FiscalError::MissingRequiredField {
260                        field: "modBCST".to_string(),
261                    }
262                })?,
263                p_mva_st: item.icms_p_mva_st,
264                p_red_bc_st: item.icms_red_bc_st,
265                v_bc_st: item
266                    .icms_v_bc_st
267                    .ok_or_else(|| FiscalError::MissingRequiredField {
268                        field: "vBCST".to_string(),
269                    })?,
270                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
271                    FiscalError::MissingRequiredField {
272                        field: "pICMSST".to_string(),
273                    }
274                })?,
275                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
276                    FiscalError::MissingRequiredField {
277                        field: "vICMSST".to_string(),
278                    }
279                })?,
280                v_bc_fcp_st: item.icms_v_bc_fcp_st,
281                p_fcp_st: item.icms_p_fcp_st,
282                v_fcp_st: item.icms_v_fcp_st,
283                v_icms_deson: item.icms_v_icms_deson,
284                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
285                ind_deduz_deson: None,
286            },
287            "40" => IcmsCst::Cst40 {
288                orig,
289                v_icms_deson: item.icms_v_icms_deson,
290                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
291                ind_deduz_deson: None,
292            },
293            "41" => IcmsCst::Cst41 {
294                orig,
295                v_icms_deson: item.icms_v_icms_deson,
296                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
297                ind_deduz_deson: None,
298            },
299            "50" => IcmsCst::Cst50 {
300                orig,
301                v_icms_deson: item.icms_v_icms_deson,
302                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
303                ind_deduz_deson: None,
304            },
305            "51" => IcmsCst::Cst51 {
306                orig,
307                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
308                p_red_bc: item.icms_red_bc,
309                c_benef_rbc: None,
310                v_bc: Some(item.total_price),
311                p_icms: Some(item.icms_rate),
312                v_icms_op: None,
313                p_dif: None,
314                v_icms_dif: None,
315                v_icms: Some(item.icms_amount),
316                v_bc_fcp: item.icms_v_bc_fcp,
317                p_fcp: item.icms_p_fcp,
318                v_fcp: item.icms_v_fcp,
319                p_fcp_dif: None,
320                v_fcp_dif: None,
321                v_fcp_efet: None,
322            },
323            "60" => IcmsCst::Cst60 {
324                orig,
325                v_bc_st_ret: None,
326                p_st: None,
327                v_icms_substituto: item.icms_v_icms_substituto,
328                v_icms_st_ret: None,
329                v_bc_fcp_st_ret: None,
330                p_fcp_st_ret: None,
331                v_fcp_st_ret: None,
332                p_red_bc_efet: None,
333                v_bc_efet: None,
334                p_icms_efet: None,
335                v_icms_efet: None,
336            },
337            "70" => IcmsCst::Cst70 {
338                orig,
339                mod_bc: item
340                    .icms_mod_bc
341                    .map(|v| v.to_string())
342                    .unwrap_or_else(|| "3".to_string()),
343                p_red_bc: item.icms_red_bc.unwrap_or(Rate(0)),
344                v_bc: item.total_price,
345                p_icms: item.icms_rate,
346                v_icms: item.icms_amount,
347                v_bc_fcp: item.icms_v_bc_fcp,
348                p_fcp: item.icms_p_fcp,
349                v_fcp: item.icms_v_fcp,
350                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
351                    FiscalError::MissingRequiredField {
352                        field: "modBCST".to_string(),
353                    }
354                })?,
355                p_mva_st: item.icms_p_mva_st,
356                p_red_bc_st: item.icms_red_bc_st,
357                v_bc_st: item
358                    .icms_v_bc_st
359                    .ok_or_else(|| FiscalError::MissingRequiredField {
360                        field: "vBCST".to_string(),
361                    })?,
362                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
363                    FiscalError::MissingRequiredField {
364                        field: "pICMSST".to_string(),
365                    }
366                })?,
367                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
368                    FiscalError::MissingRequiredField {
369                        field: "vICMSST".to_string(),
370                    }
371                })?,
372                v_bc_fcp_st: item.icms_v_bc_fcp_st,
373                p_fcp_st: item.icms_p_fcp_st,
374                v_fcp_st: item.icms_v_fcp_st,
375                v_icms_deson: item.icms_v_icms_deson,
376                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
377                ind_deduz_deson: None,
378                v_icms_st_deson: None,
379                mot_des_icms_st: None,
380            },
381            "90" => IcmsCst::Cst90 {
382                orig,
383                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
384                v_bc: Some(item.total_price),
385                p_red_bc: item.icms_red_bc,
386                c_benef_rbc: None,
387                p_icms: Some(item.icms_rate),
388                v_icms_op: None,
389                p_dif: None,
390                v_icms_dif: None,
391                v_icms: Some(item.icms_amount),
392                v_bc_fcp: item.icms_v_bc_fcp,
393                p_fcp: item.icms_p_fcp,
394                v_fcp: item.icms_v_fcp,
395                p_fcp_dif: None,
396                v_fcp_dif: None,
397                v_fcp_efet: None,
398                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()),
399                p_mva_st: item.icms_p_mva_st,
400                p_red_bc_st: item.icms_red_bc_st,
401                v_bc_st: item.icms_v_bc_st,
402                p_icms_st: item.icms_p_icms_st,
403                v_icms_st: item.icms_v_icms_st,
404                v_bc_fcp_st: item.icms_v_bc_fcp_st,
405                p_fcp_st: item.icms_p_fcp_st,
406                v_fcp_st: item.icms_v_fcp_st,
407                v_icms_deson: item.icms_v_icms_deson,
408                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
409                ind_deduz_deson: None,
410                v_icms_st_deson: None,
411                mot_des_icms_st: None,
412            },
413            other => return Err(FiscalError::UnsupportedIcmsCst(other.to_string())),
414        };
415        Ok(cst.into())
416    }
417}
418
419/// Build a `<det nItem="N">` element for one invoice item.
420pub(crate) fn build_det(
421    item: &InvoiceItemData,
422    data: &InvoiceBuildData,
423) -> Result<DetResult, FiscalError> {
424    let is_simples = matches!(
425        data.issuer.tax_regime,
426        TaxRegime::SimplesNacional | TaxRegime::SimplesExcess
427    );
428
429    let has_issqn = item.issqn.is_some();
430
431    // Build ICMS (skipped when item has ISSQN)
432    let mut icms_totals = IcmsTotals::default();
433    let icms_xml = if has_issqn {
434        String::new()
435    } else {
436        let icms_variant = build_icms_variant(item, is_simples)?;
437        tax_icms::build_icms_xml(&icms_variant, &mut icms_totals)?
438    };
439
440    // Build ISSQN (optional — only when item.issqn is set)
441    let issqn_xml = if let Some(ref issqn_data) = item.issqn {
442        tax_issqn::build_issqn_xml(issqn_data)
443    } else {
444        String::new()
445    };
446
447    // Build PIS
448    let pis_xml = tax_pis_cofins_ipi::build_pis_xml(&PisData {
449        cst: item.pis_cst.clone(),
450        v_bc: item.pis_v_bc.or(Some(Cents(0))),
451        p_pis: item.pis_p_pis.or(Some(Rate4(0))),
452        v_pis: item.pis_v_pis.or(Some(Cents(0))),
453        q_bc_prod: item.pis_q_bc_prod,
454        v_aliq_prod: item.pis_v_aliq_prod,
455    });
456
457    // Build COFINS
458    let cofins_xml = tax_pis_cofins_ipi::build_cofins_xml(&CofinsData {
459        cst: item.cofins_cst.clone(),
460        v_bc: item.cofins_v_bc.or(Some(Cents(0))),
461        p_cofins: item.cofins_p_cofins.or(Some(Rate4(0))),
462        v_cofins: item.cofins_v_cofins.or(Some(Cents(0))),
463        q_bc_prod: item.cofins_q_bc_prod,
464        v_aliq_prod: item.cofins_v_aliq_prod,
465    });
466
467    // Build IPI (optional)
468    let mut ipi_xml = String::new();
469    let mut v_ipi = 0i64;
470    if let Some(ref ipi_cst) = item.ipi_cst {
471        ipi_xml = tax_pis_cofins_ipi::build_ipi_xml(&IpiData {
472            cst: ipi_cst.clone(),
473            c_enq: item.ipi_c_enq.clone().unwrap_or_else(|| "999".to_string()),
474            v_bc: item.ipi_v_bc,
475            p_ipi: item.ipi_p_ipi,
476            v_ipi: item.ipi_v_ipi,
477            q_unid: item.ipi_q_unid,
478            v_unid: item.ipi_v_unid,
479            ..IpiData::default()
480        });
481        v_ipi = item.ipi_v_ipi.map(|c| c.0).unwrap_or(0);
482    }
483
484    // Build II (optional)
485    let mut ii_xml = String::new();
486    let mut v_ii = 0i64;
487    if let Some(ii_vbc) = item.ii_v_bc {
488        ii_xml = tax_pis_cofins_ipi::build_ii_xml(&IiData {
489            v_bc: ii_vbc,
490            v_desp_adu: item.ii_v_desp_adu.unwrap_or(Cents(0)),
491            v_ii: item.ii_v_ii.unwrap_or(Cents(0)),
492            v_iof: item.ii_v_iof.unwrap_or(Cents(0)),
493        });
494        v_ii = item.ii_v_ii.map(|c| c.0).unwrap_or(0);
495    }
496
497    // Build prod options (rastro, veicProd, med, arma, comb, nRECOPI)
498    let prod_options = build_prod_options(item);
499
500    // Build det-level extras (infAdProd, obsItem, DFeReferenciado)
501    let det_extras = build_det_extras(item);
502
503    // Assemble imposto
504    let mut imposto_children: Vec<String> = Vec::new();
505    if !icms_xml.is_empty() {
506        imposto_children.push(icms_xml);
507    }
508    if !ipi_xml.is_empty() {
509        imposto_children.push(ipi_xml);
510    }
511    imposto_children.push(pis_xml);
512    imposto_children.push(cofins_xml);
513    if !ii_xml.is_empty() {
514        imposto_children.push(ii_xml);
515    }
516    if !issqn_xml.is_empty() {
517        imposto_children.push(issqn_xml);
518    }
519
520    // Build IS (Imposto Seletivo) -- optional, inside <imposto>
521    if let Some(ref is_data) = item.is_data {
522        imposto_children.push(tax_is::build_is_xml(is_data));
523    }
524
525    // Build IBS/CBS -- optional, inside <imposto>
526    if let Some(ref ibs_cbs_data) = item.ibs_cbs {
527        imposto_children.push(tax_ibs_cbs::build_ibs_cbs_xml(ibs_cbs_data));
528    }
529
530    // Assemble prod
531    let fc2 = |c: i64| format_cents(c, 2);
532    let fc10 = |c: i64| format_cents(c, 10);
533    let fd4 = |v: f64| format_decimal(v, 4);
534
535    let mut prod_children = vec![
536        tag("cProd", &[], TagContent::Text(&item.product_code)),
537        tag(
538            "cEAN",
539            &[],
540            TagContent::Text(item.c_ean.as_deref().unwrap_or("SEM GTIN")),
541        ),
542        tag(
543            "xProd",
544            &[],
545            TagContent::Text(
546                // PHP substitutes xProd for item 1 of NFC-e in homologation
547                if item.item_number == 1
548                    && data.environment == SefazEnvironment::Homologation
549                    && data.model == InvoiceModel::Nfce
550                {
551                    HOMOLOGATION_XPROD
552                } else {
553                    &item.description
554                },
555            ),
556        ),
557        tag("NCM", &[], TagContent::Text(&item.ncm)),
558    ];
559    if let Some(ref cest) = item.cest {
560        prod_children.push(tag("CEST", &[], TagContent::Text(cest)));
561        if let Some(ref ind) = item.cest_ind_escala {
562            prod_children.push(tag("indEscala", &[], TagContent::Text(ind)));
563        }
564        if let Some(ref fab) = item.cest_cnpj_fab {
565            prod_children.push(tag("CNPJFab", &[], TagContent::Text(fab)));
566        }
567    }
568    if let Some(ref cb) = item.c_benef {
569        prod_children.push(tag("cBenef", &[], TagContent::Text(cb)));
570    }
571    if let Some(ref ex) = item.extipi {
572        prod_children.push(tag("EXTIPI", &[], TagContent::Text(ex)));
573    }
574    prod_children.extend([
575        tag("CFOP", &[], TagContent::Text(&item.cfop)),
576        tag("uCom", &[], TagContent::Text(&item.unit_of_measure)),
577        tag("qCom", &[], TagContent::Text(&fd4(item.quantity))),
578        tag("vUnCom", &[], TagContent::Text(&fc10(item.unit_price.0))),
579        tag("vProd", &[], TagContent::Text(&fc2(item.total_price.0))),
580        tag(
581            "cEANTrib",
582            &[],
583            TagContent::Text(item.c_ean_trib.as_deref().unwrap_or("SEM GTIN")),
584        ),
585        tag("uTrib", &[], TagContent::Text(&item.unit_of_measure)),
586        tag("qTrib", &[], TagContent::Text(&fd4(item.quantity))),
587        tag("vUnTrib", &[], TagContent::Text(&fc10(item.unit_price.0))),
588    ]);
589    if let Some(v) = item.v_frete {
590        prod_children.push(tag("vFrete", &[], TagContent::Text(&fc2(v.0))));
591    }
592    if let Some(v) = item.v_seg {
593        prod_children.push(tag("vSeg", &[], TagContent::Text(&fc2(v.0))));
594    }
595    if let Some(v) = item.v_desc {
596        prod_children.push(tag("vDesc", &[], TagContent::Text(&fc2(v.0))));
597    }
598    if let Some(v) = item.v_outro {
599        prod_children.push(tag("vOutro", &[], TagContent::Text(&fc2(v.0))));
600    }
601    let ind_tot_str = match item.ind_tot {
602        Some(v) => v.to_string(),
603        None => "1".to_string(),
604    };
605    prod_children.push(tag("indTot", &[], TagContent::Text(&ind_tot_str)));
606    // DI (Declaração de Importação) — after indTot, before detExport
607    if let Some(ref dis) = item.di {
608        for di in dis.iter().take(100) {
609            prod_children.push(build_di_xml(di));
610        }
611    }
612    // detExport — after DI, before xPed
613    if let Some(ref exports) = item.det_export {
614        for dex in exports.iter().take(500) {
615            prod_children.push(build_det_export_xml(dex));
616        }
617    }
618    if let Some(ref xped) = item.x_ped {
619        prod_children.push(tag("xPed", &[], TagContent::Text(xped)));
620    }
621    if let Some(ref nip) = item.n_item_ped {
622        prod_children.push(tag("nItemPed", &[], TagContent::Text(nip)));
623    }
624    if let Some(ref nfci) = item.n_fci {
625        prod_children.push(tag("nFCI", &[], TagContent::Text(nfci)));
626    }
627    prod_children.extend(prod_options);
628
629    // impostoDevol (after imposto, before infAdProd)
630    let mut v_ipi_devol = 0i64;
631    let imposto_devol_xml = if let Some(ref devol) = item.imposto_devol {
632        v_ipi_devol = devol.v_ipi_devol.0;
633        let p_devol_str = format_decimal(devol.p_devol.0 as f64 / 100.0, 2);
634        let v_ipi_devol_str = format_cents(devol.v_ipi_devol.0, 2);
635        tag(
636            "impostoDevol",
637            &[],
638            TagContent::Children(vec![
639                tag("pDevol", &[], TagContent::Text(&p_devol_str)),
640                tag(
641                    "IPI",
642                    &[],
643                    TagContent::Children(vec![tag(
644                        "vIPIDevol",
645                        &[],
646                        TagContent::Text(&v_ipi_devol_str),
647                    )]),
648                ),
649            ]),
650        )
651    } else {
652        String::new()
653    };
654
655    // Assemble det
656    let nitem = item.item_number.to_string();
657    let mut det_children = vec![
658        tag("prod", &[], TagContent::Children(prod_children)),
659        tag("imposto", &[], TagContent::Children(imposto_children)),
660    ];
661    if !imposto_devol_xml.is_empty() {
662        det_children.push(imposto_devol_xml);
663    }
664    det_children.extend(det_extras);
665
666    let xml = tag(
667        "det",
668        &[("nItem", &nitem)],
669        TagContent::Children(det_children),
670    );
671
672    Ok(DetResult {
673        xml,
674        icms_totals,
675        v_ipi,
676        v_pis: item.pis_v_pis.map(|c| c.0).unwrap_or(0),
677        v_cofins: item.cofins_v_cofins.map(|c| c.0).unwrap_or(0),
678        v_ii,
679        v_frete: item.v_frete.map(|c| c.0).unwrap_or(0),
680        v_seg: item.v_seg.map(|c| c.0).unwrap_or(0),
681        v_desc: item.v_desc.map(|c| c.0).unwrap_or(0),
682        v_outro: item.v_outro.map(|c| c.0).unwrap_or(0),
683        ind_tot: item.ind_tot.unwrap_or(1),
684        v_tot_trib: item.v_tot_trib.map(|c| c.0).unwrap_or(0),
685        v_ipi_devol,
686        has_issqn,
687    })
688}
689
690fn build_prod_options(item: &InvoiceItemData) -> Vec<String> {
691    let mut opts = Vec::new();
692
693    // rastro (batch tracking)
694    if let Some(ref rastros) = item.rastro {
695        for r in rastros.iter().take(500) {
696            let mut rastro_children = vec![
697                tag("nLote", &[], TagContent::Text(&r.n_lote)),
698                tag("qLote", &[], TagContent::Text(&format_decimal(r.q_lote, 3))),
699                tag("dFab", &[], TagContent::Text(&r.d_fab)),
700                tag("dVal", &[], TagContent::Text(&r.d_val)),
701            ];
702            if let Some(ref agreg) = r.c_agreg {
703                rastro_children.push(tag("cAgreg", &[], TagContent::Text(agreg)));
704            }
705            opts.push(tag("rastro", &[], TagContent::Children(rastro_children)));
706        }
707    }
708
709    // CHOICE group: veicProd, med, arma, nRECOPI (mutually exclusive)
710    if let Some(ref v) = item.veic_prod {
711        opts.push(tag(
712            "veicProd",
713            &[],
714            TagContent::Children(vec![
715                tag("tpOp", &[], TagContent::Text(&v.tp_op)),
716                tag("chassi", &[], TagContent::Text(&v.chassi)),
717                tag("cCor", &[], TagContent::Text(&v.c_cor)),
718                tag("xCor", &[], TagContent::Text(&v.x_cor)),
719                tag("pot", &[], TagContent::Text(&v.pot)),
720                tag("cilin", &[], TagContent::Text(&v.cilin)),
721                tag("pesoL", &[], TagContent::Text(&v.peso_l)),
722                tag("pesoB", &[], TagContent::Text(&v.peso_b)),
723                tag("nSerie", &[], TagContent::Text(&v.n_serie)),
724                tag("tpComb", &[], TagContent::Text(&v.tp_comb)),
725                tag("nMotor", &[], TagContent::Text(&v.n_motor)),
726                tag("CMT", &[], TagContent::Text(&v.cmt)),
727                tag("dist", &[], TagContent::Text(&v.dist)),
728                tag("anoMod", &[], TagContent::Text(&v.ano_mod)),
729                tag("anoFab", &[], TagContent::Text(&v.ano_fab)),
730                tag("tpPint", &[], TagContent::Text(&v.tp_pint)),
731                tag("tpVeic", &[], TagContent::Text(&v.tp_veic)),
732                tag("espVeic", &[], TagContent::Text(&v.esp_veic)),
733                tag("VIN", &[], TagContent::Text(&v.vin)),
734                tag("condVeic", &[], TagContent::Text(&v.cond_veic)),
735                tag("cMod", &[], TagContent::Text(&v.c_mod)),
736                tag("cCorDENATRAN", &[], TagContent::Text(&v.c_cor_denatran)),
737                tag("lota", &[], TagContent::Text(&v.lota)),
738                tag("tpRest", &[], TagContent::Text(&v.tp_rest)),
739            ]),
740        ));
741    } else if let Some(ref m) = item.med {
742        let mut med_children = Vec::new();
743        if let Some(ref code) = m.c_prod_anvisa {
744            med_children.push(tag("cProdANVISA", &[], TagContent::Text(code)));
745        }
746        if let Some(ref reason) = m.x_motivo_isencao {
747            med_children.push(tag("xMotivoIsencao", &[], TagContent::Text(reason)));
748        }
749        med_children.push(tag(
750            "vPMC",
751            &[],
752            TagContent::Text(&format_cents(m.v_pmc.0, 2)),
753        ));
754        opts.push(tag("med", &[], TagContent::Children(med_children)));
755    } else if let Some(ref arms) = item.arma {
756        for a in arms.iter().take(500) {
757            opts.push(tag(
758                "arma",
759                &[],
760                TagContent::Children(vec![
761                    tag("tpArma", &[], TagContent::Text(&a.tp_arma)),
762                    tag("nSerie", &[], TagContent::Text(&a.n_serie)),
763                    tag("nCano", &[], TagContent::Text(&a.n_cano)),
764                    tag("descr", &[], TagContent::Text(&a.descr)),
765                ]),
766            ));
767        }
768    } else if let Some(ref recopi) = item.n_recopi {
769        if !recopi.is_empty() {
770            opts.push(tag("nRECOPI", &[], TagContent::Text(recopi)));
771        }
772    }
773
774    // comb — fuel product data (after the CHOICE group, per NF-e schema order)
775    if let Some(ref comb) = item.comb {
776        opts.push(build_comb_xml(comb));
777    }
778
779    opts
780}
781
782/// Build a single `<DI>` element with its nested `<adi>` children.
783fn build_di_xml(di: &crate::types::DiData) -> String {
784    let mut children = vec![
785        tag("nDI", &[], TagContent::Text(&di.n_di)),
786        tag("dDI", &[], TagContent::Text(&di.d_di)),
787        tag("xLocDesemb", &[], TagContent::Text(&di.x_loc_desemb)),
788        tag("UFDesemb", &[], TagContent::Text(&di.uf_desemb)),
789        tag("dDesemb", &[], TagContent::Text(&di.d_desemb)),
790        tag("tpViaTransp", &[], TagContent::Text(&di.tp_via_transp)),
791    ];
792    if let Some(ref v) = di.v_afrmm {
793        children.push(tag("vAFRMM", &[], TagContent::Text(&format_cents(v.0, 2))));
794    }
795    children.push(tag(
796        "tpIntermedio",
797        &[],
798        TagContent::Text(&di.tp_intermedio),
799    ));
800    if let Some(ref cnpj) = di.cnpj {
801        children.push(tag("CNPJ", &[], TagContent::Text(cnpj)));
802    } else if let Some(ref cpf) = di.cpf {
803        children.push(tag("CPF", &[], TagContent::Text(cpf)));
804    }
805    if let Some(ref uf) = di.uf_terceiro {
806        children.push(tag("UFTerceiro", &[], TagContent::Text(uf)));
807    }
808    children.push(tag("cExportador", &[], TagContent::Text(&di.c_exportador)));
809    // adi children (up to 999 per DI)
810    for adi in di.adi.iter().take(999) {
811        let mut adi_children = Vec::new();
812        if let Some(ref n) = adi.n_adicao {
813            adi_children.push(tag("nAdicao", &[], TagContent::Text(n)));
814        }
815        adi_children.push(tag("nSeqAdic", &[], TagContent::Text(&adi.n_seq_adic)));
816        adi_children.push(tag("cFabricante", &[], TagContent::Text(&adi.c_fabricante)));
817        if let Some(ref v) = adi.v_desc_di {
818            adi_children.push(tag("vDescDI", &[], TagContent::Text(&format_cents(v.0, 2))));
819        }
820        if let Some(ref n) = adi.n_draw {
821            adi_children.push(tag("nDraw", &[], TagContent::Text(n)));
822        }
823        children.push(tag("adi", &[], TagContent::Children(adi_children)));
824    }
825    tag("DI", &[], TagContent::Children(children))
826}
827
828/// Build a single `<detExport>` element with optional `<exportInd>`.
829fn build_det_export_xml(dex: &crate::types::DetExportData) -> String {
830    let mut children = Vec::new();
831    if let Some(ref n) = dex.n_draw {
832        children.push(tag("nDraw", &[], TagContent::Text(n)));
833    }
834    if dex.n_re.is_some() || dex.ch_nfe.is_some() || dex.q_export.is_some() {
835        let mut exp_ind_children = Vec::new();
836        if let Some(ref n) = dex.n_re {
837            exp_ind_children.push(tag("nRE", &[], TagContent::Text(n)));
838        }
839        if let Some(ref ch) = dex.ch_nfe {
840            exp_ind_children.push(tag("chNFe", &[], TagContent::Text(ch)));
841        }
842        if let Some(q) = dex.q_export {
843            exp_ind_children.push(tag("qExport", &[], TagContent::Text(&format_decimal(q, 4))));
844        }
845        children.push(tag(
846            "exportInd",
847            &[],
848            TagContent::Children(exp_ind_children),
849        ));
850    }
851    tag("detExport", &[], TagContent::Children(children))
852}
853
854/// Build the `<comb>` element for fuel products.
855///
856/// Follows the PHP sped-nfe `tagcomb` / `tagencerrante` / `tagorigComb`
857/// structure exactly: cProdANP, descANP, pGLP, pGNn, pGNi, vPart, CODIF,
858/// qTemp, UFCons, CIDE, encerrante, pBio, origComb[].
859fn build_comb_xml(comb: &CombData) -> String {
860    let mut children = vec![
861        tag("cProdANP", &[], TagContent::Text(&comb.c_prod_anp)),
862        tag("descANP", &[], TagContent::Text(&comb.desc_anp)),
863    ];
864
865    if let Some(ref v) = comb.p_glp {
866        children.push(tag("pGLP", &[], TagContent::Text(v)));
867    }
868    if let Some(ref v) = comb.p_gn_n {
869        children.push(tag("pGNn", &[], TagContent::Text(v)));
870    }
871    if let Some(ref v) = comb.p_gn_i {
872        children.push(tag("pGNi", &[], TagContent::Text(v)));
873    }
874    if let Some(ref v) = comb.v_part {
875        children.push(tag("vPart", &[], TagContent::Text(v)));
876    }
877    if let Some(ref v) = comb.codif {
878        children.push(tag("CODIF", &[], TagContent::Text(v)));
879    }
880    if let Some(ref v) = comb.q_temp {
881        children.push(tag("qTemp", &[], TagContent::Text(v)));
882    }
883
884    children.push(tag("UFCons", &[], TagContent::Text(&comb.uf_cons)));
885
886    // CIDE (conditional — only when qBCProd is present)
887    if let Some(ref cide) = comb.cide {
888        let cide_children = vec![
889            tag("qBCProd", &[], TagContent::Text(&cide.q_bc_prod)),
890            tag("vAliqProd", &[], TagContent::Text(&cide.v_aliq_prod)),
891            tag("vCIDE", &[], TagContent::Text(&cide.v_cide)),
892        ];
893        children.push(tag("CIDE", &[], TagContent::Children(cide_children)));
894    }
895
896    // encerrante
897    if let Some(ref enc) = comb.encerrante {
898        let mut enc_children = vec![tag("nBico", &[], TagContent::Text(&enc.n_bico))];
899        if let Some(ref bomba) = enc.n_bomba {
900            enc_children.push(tag("nBomba", &[], TagContent::Text(bomba)));
901        }
902        enc_children.push(tag("nTanque", &[], TagContent::Text(&enc.n_tanque)));
903        enc_children.push(tag("vEncIni", &[], TagContent::Text(&enc.v_enc_ini)));
904        enc_children.push(tag("vEncFin", &[], TagContent::Text(&enc.v_enc_fin)));
905        children.push(tag("encerrante", &[], TagContent::Children(enc_children)));
906    }
907
908    // pBio
909    if let Some(ref v) = comb.p_bio {
910        children.push(tag("pBio", &[], TagContent::Text(v)));
911    }
912
913    // origComb (may be multiple)
914    if let Some(ref origins) = comb.orig_comb {
915        for orig in origins {
916            let orig_children = vec![
917                tag("indImport", &[], TagContent::Text(&orig.ind_import)),
918                tag("cUFOrig", &[], TagContent::Text(&orig.c_uf_orig)),
919                tag("pOrig", &[], TagContent::Text(&orig.p_orig)),
920            ];
921            children.push(tag("origComb", &[], TagContent::Children(orig_children)));
922        }
923    }
924
925    tag("comb", &[], TagContent::Children(children))
926}
927
928fn build_det_extras(item: &InvoiceItemData) -> Vec<String> {
929    let mut extras = Vec::new();
930
931    if let Some(ref info) = item.inf_ad_prod {
932        extras.push(tag("infAdProd", &[], TagContent::Text(info)));
933    }
934
935    if let Some(ref obs) = item.obs_item {
936        let mut obs_children = Vec::new();
937        if let Some(ref cont) = obs.obs_cont {
938            obs_children.push(tag(
939                "obsCont",
940                &[("xCampo", &cont.x_campo)],
941                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&cont.x_texto))]),
942            ));
943        }
944        if let Some(ref fisco) = obs.obs_fisco {
945            obs_children.push(tag(
946                "obsFisco",
947                &[("xCampo", &fisco.x_campo)],
948                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&fisco.x_texto))]),
949            ));
950        }
951        extras.push(tag("obsItem", &[], TagContent::Children(obs_children)));
952    }
953
954    if let Some(ref dfe) = item.dfe_referenciado {
955        let mut dfe_children = vec![tag("chaveAcesso", &[], TagContent::Text(&dfe.chave_acesso))];
956        if let Some(ref n) = dfe.n_item {
957            dfe_children.push(tag("nItem", &[], TagContent::Text(n)));
958        }
959        extras.push(tag(
960            "DFeReferenciado",
961            &[],
962            TagContent::Children(dfe_children),
963        ));
964    }
965
966    extras
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    use crate::newtypes::{Cents, IbgeCode, Rate};
973    use crate::tax_issqn::IssqnData as TaxIssqnData;
974    use crate::types::{
975        CideData, CombData, EncerranteData, InvoiceItemData, InvoiceModel, IssuerData,
976        OrigCombData, SefazEnvironment, TaxRegime,
977    };
978
979    fn sample_build_data() -> InvoiceBuildData {
980        let issuer = IssuerData::new(
981            "12345678000199",
982            "123456789",
983            "Test Company",
984            TaxRegime::SimplesNacional,
985            "SP",
986            IbgeCode("3550308".to_string()),
987            "Sao Paulo",
988            "Av Paulista",
989            "1000",
990            "Bela Vista",
991            "01310100",
992        );
993
994        InvoiceBuildData {
995            model: InvoiceModel::Nfe,
996            series: 1,
997            number: 1,
998            emission_type: crate::types::EmissionType::Normal,
999            environment: SefazEnvironment::Homologation,
1000            issued_at: chrono::Utc::now()
1001                .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).expect("valid offset")),
1002            operation_nature: "VENDA".to_string(),
1003            issuer,
1004            recipient: None,
1005            items: Vec::new(),
1006            payments: Vec::new(),
1007            change_amount: None,
1008            payment_card_details: None,
1009            contingency: None,
1010            exit_at: None,
1011            operation_type: None,
1012            purpose_code: None,
1013            intermediary_indicator: None,
1014            emission_process: None,
1015            consumer_type: None,
1016            buyer_presence: None,
1017            print_format: None,
1018            references: None,
1019            transport: None,
1020            billing: None,
1021            withdrawal: None,
1022            delivery: None,
1023            authorized_xml: None,
1024            additional_info: None,
1025            intermediary: None,
1026            ret_trib: None,
1027            tech_responsible: None,
1028            purchase: None,
1029            export: None,
1030            issqn_tot: None,
1031            cana: None,
1032            agropecuario: None,
1033            compra_gov: None,
1034            pag_antecipado: None,
1035            is_tot: None,
1036            ibs_cbs_tot: None,
1037            destination_indicator: None,
1038            ver_proc: None,
1039        }
1040    }
1041
1042    fn sample_item() -> InvoiceItemData {
1043        InvoiceItemData::new(
1044            1,
1045            "001",
1046            "Gasolina Comum",
1047            "27101259",
1048            "5102",
1049            "LT",
1050            50.0,
1051            Cents(599),
1052            Cents(29950),
1053            "102",
1054            Rate(0),
1055            Cents(0),
1056            "99",
1057            "99",
1058        )
1059    }
1060
1061    // ── Combustíveis ────────────────────────────────────────────────────────
1062
1063    #[test]
1064    fn comb_minimal_produces_correct_xml() {
1065        let comb = CombData::new("210203001", "GLP", "SP");
1066        let xml = build_comb_xml(&comb);
1067
1068        assert_eq!(
1069            xml,
1070            "<comb>\
1071                <cProdANP>210203001</cProdANP>\
1072                <descANP>GLP</descANP>\
1073                <UFCons>SP</UFCons>\
1074            </comb>"
1075        );
1076    }
1077
1078    #[test]
1079    fn comb_with_glp_percentages() {
1080        let comb = CombData::new("210203001", "GLP", "SP")
1081            .p_glp("60.0000")
1082            .p_gn_n("25.0000")
1083            .p_gn_i("15.0000")
1084            .v_part("3.50");
1085
1086        let xml = build_comb_xml(&comb);
1087
1088        assert_eq!(
1089            xml,
1090            "<comb>\
1091                <cProdANP>210203001</cProdANP>\
1092                <descANP>GLP</descANP>\
1093                <pGLP>60.0000</pGLP>\
1094                <pGNn>25.0000</pGNn>\
1095                <pGNi>15.0000</pGNi>\
1096                <vPart>3.50</vPart>\
1097                <UFCons>SP</UFCons>\
1098            </comb>"
1099        );
1100    }
1101
1102    #[test]
1103    fn comb_with_codif_and_qtemp() {
1104        let comb = CombData::new("320102001", "GASOLINA COMUM", "PR")
1105            .codif("123456789")
1106            .q_temp("1000.0000");
1107
1108        let xml = build_comb_xml(&comb);
1109
1110        assert_eq!(
1111            xml,
1112            "<comb>\
1113                <cProdANP>320102001</cProdANP>\
1114                <descANP>GASOLINA COMUM</descANP>\
1115                <CODIF>123456789</CODIF>\
1116                <qTemp>1000.0000</qTemp>\
1117                <UFCons>PR</UFCons>\
1118            </comb>"
1119        );
1120    }
1121
1122    #[test]
1123    fn comb_with_cide() {
1124        let cide = CideData::new("1000.0000", "0.0700", "70.00");
1125        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").cide(cide);
1126
1127        let xml = build_comb_xml(&comb);
1128
1129        assert_eq!(
1130            xml,
1131            "<comb>\
1132                <cProdANP>320102001</cProdANP>\
1133                <descANP>GASOLINA COMUM</descANP>\
1134                <UFCons>SP</UFCons>\
1135                <CIDE>\
1136                    <qBCProd>1000.0000</qBCProd>\
1137                    <vAliqProd>0.0700</vAliqProd>\
1138                    <vCIDE>70.00</vCIDE>\
1139                </CIDE>\
1140            </comb>"
1141        );
1142    }
1143
1144    #[test]
1145    fn comb_with_encerrante() {
1146        let enc = EncerranteData::new("1", "1", "1234.567", "1284.567").n_bomba("2");
1147        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").encerrante(enc);
1148
1149        let xml = build_comb_xml(&comb);
1150
1151        assert_eq!(
1152            xml,
1153            "<comb>\
1154                <cProdANP>320102001</cProdANP>\
1155                <descANP>GASOLINA COMUM</descANP>\
1156                <UFCons>SP</UFCons>\
1157                <encerrante>\
1158                    <nBico>1</nBico>\
1159                    <nBomba>2</nBomba>\
1160                    <nTanque>1</nTanque>\
1161                    <vEncIni>1234.567</vEncIni>\
1162                    <vEncFin>1284.567</vEncFin>\
1163                </encerrante>\
1164            </comb>"
1165        );
1166    }
1167
1168    #[test]
1169    fn comb_encerrante_without_bomba() {
1170        let enc = EncerranteData::new("3", "2", "5000.000", "5050.000");
1171        let comb = CombData::new("320102001", "GASOLINA COMUM", "RJ").encerrante(enc);
1172
1173        let xml = build_comb_xml(&comb);
1174
1175        assert_eq!(
1176            xml,
1177            "<comb>\
1178                <cProdANP>320102001</cProdANP>\
1179                <descANP>GASOLINA COMUM</descANP>\
1180                <UFCons>RJ</UFCons>\
1181                <encerrante>\
1182                    <nBico>3</nBico>\
1183                    <nTanque>2</nTanque>\
1184                    <vEncIni>5000.000</vEncIni>\
1185                    <vEncFin>5050.000</vEncFin>\
1186                </encerrante>\
1187            </comb>"
1188        );
1189    }
1190
1191    #[test]
1192    fn comb_with_pbio() {
1193        let comb = CombData::new("810102001", "OLEO DIESEL B S10", "SP").p_bio("15.0000");
1194
1195        let xml = build_comb_xml(&comb);
1196
1197        assert_eq!(
1198            xml,
1199            "<comb>\
1200                <cProdANP>810102001</cProdANP>\
1201                <descANP>OLEO DIESEL B S10</descANP>\
1202                <UFCons>SP</UFCons>\
1203                <pBio>15.0000</pBio>\
1204            </comb>"
1205        );
1206    }
1207
1208    #[test]
1209    fn comb_with_orig_comb_single() {
1210        let orig = OrigCombData::new("0", "35", "100.0000");
1211        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig]);
1212
1213        let xml = build_comb_xml(&comb);
1214
1215        assert_eq!(
1216            xml,
1217            "<comb>\
1218                <cProdANP>320102001</cProdANP>\
1219                <descANP>GASOLINA COMUM</descANP>\
1220                <UFCons>SP</UFCons>\
1221                <origComb>\
1222                    <indImport>0</indImport>\
1223                    <cUFOrig>35</cUFOrig>\
1224                    <pOrig>100.0000</pOrig>\
1225                </origComb>\
1226            </comb>"
1227        );
1228    }
1229
1230    #[test]
1231    fn comb_with_orig_comb_multiple() {
1232        let orig1 = OrigCombData::new("0", "35", "70.0000");
1233        let orig2 = OrigCombData::new("1", "99", "30.0000");
1234        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig1, orig2]);
1235
1236        let xml = build_comb_xml(&comb);
1237
1238        assert_eq!(
1239            xml,
1240            "<comb>\
1241                <cProdANP>320102001</cProdANP>\
1242                <descANP>GASOLINA COMUM</descANP>\
1243                <UFCons>SP</UFCons>\
1244                <origComb>\
1245                    <indImport>0</indImport>\
1246                    <cUFOrig>35</cUFOrig>\
1247                    <pOrig>70.0000</pOrig>\
1248                </origComb>\
1249                <origComb>\
1250                    <indImport>1</indImport>\
1251                    <cUFOrig>99</cUFOrig>\
1252                    <pOrig>30.0000</pOrig>\
1253                </origComb>\
1254            </comb>"
1255        );
1256    }
1257
1258    #[test]
1259    fn comb_full_with_all_fields() {
1260        let cide = CideData::new("500.0000", "0.0700", "35.00");
1261        let enc = EncerranteData::new("1", "1", "10000.000", "10050.000").n_bomba("1");
1262        let orig = OrigCombData::new("0", "35", "100.0000");
1263
1264        let comb = CombData::new("210203001", "GLP", "SP")
1265            .p_glp("60.0000")
1266            .p_gn_n("25.0000")
1267            .p_gn_i("15.0000")
1268            .v_part("3.50")
1269            .codif("999888777")
1270            .q_temp("500.0000")
1271            .cide(cide)
1272            .encerrante(enc)
1273            .p_bio("12.0000")
1274            .orig_comb(vec![orig]);
1275
1276        let xml = build_comb_xml(&comb);
1277
1278        assert_eq!(
1279            xml,
1280            "<comb>\
1281                <cProdANP>210203001</cProdANP>\
1282                <descANP>GLP</descANP>\
1283                <pGLP>60.0000</pGLP>\
1284                <pGNn>25.0000</pGNn>\
1285                <pGNi>15.0000</pGNi>\
1286                <vPart>3.50</vPart>\
1287                <CODIF>999888777</CODIF>\
1288                <qTemp>500.0000</qTemp>\
1289                <UFCons>SP</UFCons>\
1290                <CIDE>\
1291                    <qBCProd>500.0000</qBCProd>\
1292                    <vAliqProd>0.0700</vAliqProd>\
1293                    <vCIDE>35.00</vCIDE>\
1294                </CIDE>\
1295                <encerrante>\
1296                    <nBico>1</nBico>\
1297                    <nBomba>1</nBomba>\
1298                    <nTanque>1</nTanque>\
1299                    <vEncIni>10000.000</vEncIni>\
1300                    <vEncFin>10050.000</vEncFin>\
1301                </encerrante>\
1302                <pBio>12.0000</pBio>\
1303                <origComb>\
1304                    <indImport>0</indImport>\
1305                    <cUFOrig>35</cUFOrig>\
1306                    <pOrig>100.0000</pOrig>\
1307                </origComb>\
1308            </comb>"
1309        );
1310    }
1311
1312    #[test]
1313    fn comb_in_det_xml() {
1314        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP");
1315        let item = sample_item().comb(comb);
1316        let data = sample_build_data();
1317        let result = build_det(&item, &data).expect("build_det should succeed");
1318
1319        // <comb> appears inside <prod>
1320        let prod_start = result.xml.find("<prod>").expect("<prod> must exist");
1321        let prod_end = result.xml.find("</prod>").expect("</prod> must exist");
1322        let prod_section = &result.xml[prod_start..prod_end];
1323
1324        assert!(prod_section.contains("<comb>"));
1325        assert!(prod_section.contains("<cProdANP>320102001</cProdANP>"));
1326        assert!(prod_section.contains("<descANP>GASOLINA COMUM</descANP>"));
1327        assert!(prod_section.contains("<UFCons>SP</UFCons>"));
1328        assert!(prod_section.contains("</comb>"));
1329    }
1330
1331    // ── ISSQN ───────────────────────────────────────────────────────────────
1332
1333    #[test]
1334    fn issqn_item_produces_issqn_tag_not_icms() {
1335        let issqn_data = TaxIssqnData::new(10000, 500, 500, "3550308", "14.01")
1336            .ind_iss("1")
1337            .ind_incentivo("2");
1338        let item = sample_item().issqn(issqn_data);
1339        let data = sample_build_data();
1340        let result = build_det(&item, &data).expect("build_det should succeed");
1341
1342        // ISSQN tag present inside <imposto>
1343        assert!(result.xml.contains("<ISSQN>"));
1344        assert!(result.xml.contains("<vBC>100.00</vBC>"));
1345        assert!(result.xml.contains("<vAliq>5.0000</vAliq>"));
1346        assert!(result.xml.contains("<vISSQN>5.00</vISSQN>"));
1347        assert!(result.xml.contains("<cMunFG>3550308</cMunFG>"));
1348        assert!(result.xml.contains("<cListServ>14.01</cListServ>"));
1349        assert!(result.xml.contains("<indISS>1</indISS>"));
1350        assert!(result.xml.contains("<indIncentivo>2</indIncentivo>"));
1351        assert!(result.xml.contains("</ISSQN>"));
1352
1353        // ICMS should NOT be present for ISSQN items
1354        assert!(!result.xml.contains("<ICMS>"));
1355        assert!(!result.xml.contains("</ICMS>"));
1356        assert!(result.has_issqn);
1357    }
1358
1359    #[test]
1360    fn issqn_item_with_all_optional_fields() {
1361        let issqn_data = TaxIssqnData::new(20000, 300, 600, "3304557", "07.02")
1362            .v_deducao(1000)
1363            .v_outro(500)
1364            .v_desc_incond(200)
1365            .v_desc_cond(100)
1366            .v_iss_ret(300)
1367            .ind_iss("1")
1368            .c_servico("1234")
1369            .c_mun("3304557")
1370            .c_pais("1058")
1371            .n_processo("ABC123")
1372            .ind_incentivo("1");
1373
1374        let item = sample_item().issqn(issqn_data);
1375        let data = sample_build_data();
1376        let result = build_det(&item, &data).expect("build_det should succeed");
1377
1378        assert!(result.xml.contains("<vBC>200.00</vBC>"));
1379        assert!(result.xml.contains("<vAliq>3.0000</vAliq>"));
1380        assert!(result.xml.contains("<vISSQN>6.00</vISSQN>"));
1381        assert!(result.xml.contains("<vDeducao>10.00</vDeducao>"));
1382        assert!(result.xml.contains("<vOutro>5.00</vOutro>"));
1383        assert!(result.xml.contains("<vDescIncond>2.00</vDescIncond>"));
1384        assert!(result.xml.contains("<vDescCond>1.00</vDescCond>"));
1385        assert!(result.xml.contains("<vISSRet>3.00</vISSRet>"));
1386        assert!(result.xml.contains("<cServico>1234</cServico>"));
1387        assert!(result.xml.contains("<cMun>3304557</cMun>"));
1388        assert!(result.xml.contains("<cPais>1058</cPais>"));
1389        assert!(result.xml.contains("<nProcesso>ABC123</nProcesso>"));
1390        assert!(result.xml.contains("<indIncentivo>1</indIncentivo>"));
1391        assert!(result.has_issqn);
1392    }
1393
1394    #[test]
1395    fn non_issqn_item_has_icms_and_no_issqn() {
1396        let item = sample_item();
1397        let data = sample_build_data();
1398        let result = build_det(&item, &data).expect("build_det should succeed");
1399
1400        assert!(result.xml.contains("<ICMS"));
1401        assert!(!result.xml.contains("<ISSQN>"));
1402        assert!(!result.has_issqn);
1403    }
1404
1405    // ── Declaração de Importação (DI) ──────────────────────────────────────
1406
1407    #[test]
1408    fn di_minimal_with_one_adi() {
1409        use crate::types::{AdiData, DiData};
1410
1411        let adi = AdiData::new("1", "FABRICANTE_X").n_adicao("001");
1412        let di = DiData::new(
1413            "1234567890",
1414            "2025-01-15",
1415            "Santos",
1416            "SP",
1417            "2025-01-20",
1418            "1",
1419            "1",
1420            "EXP001",
1421            vec![adi],
1422        );
1423        let xml = build_di_xml(&di);
1424
1425        assert_eq!(
1426            xml,
1427            "<DI>\
1428                <nDI>1234567890</nDI>\
1429                <dDI>2025-01-15</dDI>\
1430                <xLocDesemb>Santos</xLocDesemb>\
1431                <UFDesemb>SP</UFDesemb>\
1432                <dDesemb>2025-01-20</dDesemb>\
1433                <tpViaTransp>1</tpViaTransp>\
1434                <tpIntermedio>1</tpIntermedio>\
1435                <cExportador>EXP001</cExportador>\
1436                <adi>\
1437                    <nAdicao>001</nAdicao>\
1438                    <nSeqAdic>1</nSeqAdic>\
1439                    <cFabricante>FABRICANTE_X</cFabricante>\
1440                </adi>\
1441            </DI>"
1442        );
1443    }
1444
1445    #[test]
1446    fn di_with_all_optional_fields() {
1447        use crate::types::{AdiData, DiData};
1448
1449        let adi = AdiData::new("1", "FAB_Y")
1450            .n_adicao("002")
1451            .v_desc_di(Cents(15000))
1452            .n_draw("20259999999");
1453        let di = DiData::new(
1454            "DI-2025-001",
1455            "2025-03-01",
1456            "Paranagua",
1457            "PR",
1458            "2025-03-05",
1459            "1",
1460            "2",
1461            "EXP002",
1462            vec![adi],
1463        )
1464        .v_afrmm(Cents(5000))
1465        .cnpj("12345678000199")
1466        .uf_terceiro("RJ");
1467
1468        let xml = build_di_xml(&di);
1469
1470        assert_eq!(
1471            xml,
1472            "<DI>\
1473                <nDI>DI-2025-001</nDI>\
1474                <dDI>2025-03-01</dDI>\
1475                <xLocDesemb>Paranagua</xLocDesemb>\
1476                <UFDesemb>PR</UFDesemb>\
1477                <dDesemb>2025-03-05</dDesemb>\
1478                <tpViaTransp>1</tpViaTransp>\
1479                <vAFRMM>50.00</vAFRMM>\
1480                <tpIntermedio>2</tpIntermedio>\
1481                <CNPJ>12345678000199</CNPJ>\
1482                <UFTerceiro>RJ</UFTerceiro>\
1483                <cExportador>EXP002</cExportador>\
1484                <adi>\
1485                    <nAdicao>002</nAdicao>\
1486                    <nSeqAdic>1</nSeqAdic>\
1487                    <cFabricante>FAB_Y</cFabricante>\
1488                    <vDescDI>150.00</vDescDI>\
1489                    <nDraw>20259999999</nDraw>\
1490                </adi>\
1491            </DI>"
1492        );
1493    }
1494
1495    #[test]
1496    fn di_with_cpf_instead_of_cnpj() {
1497        use crate::types::{AdiData, DiData};
1498
1499        let adi = AdiData::new("1", "FAB_Z");
1500        let di = DiData::new(
1501            "DI-CPF",
1502            "2025-06-01",
1503            "Recife",
1504            "PE",
1505            "2025-06-03",
1506            "7",
1507            "3",
1508            "EXP003",
1509            vec![adi],
1510        )
1511        .cpf("12345678901");
1512
1513        let xml = build_di_xml(&di);
1514        assert!(xml.contains("<CPF>12345678901</CPF>"));
1515        assert!(!xml.contains("<CNPJ>"));
1516    }
1517
1518    #[test]
1519    fn di_with_multiple_adi() {
1520        use crate::types::{AdiData, DiData};
1521
1522        let adi1 = AdiData::new("1", "FAB_A").n_adicao("001");
1523        let adi2 = AdiData::new("2", "FAB_B").n_adicao("001");
1524        let di = DiData::new(
1525            "DI-MULTI",
1526            "2025-01-01",
1527            "Santos",
1528            "SP",
1529            "2025-01-05",
1530            "1",
1531            "1",
1532            "EXP-M",
1533            vec![adi1, adi2],
1534        );
1535        let xml = build_di_xml(&di);
1536
1537        // Both adi elements present
1538        let count = xml.matches("<adi>").count();
1539        assert_eq!(count, 2, "expected 2 <adi> elements, got {count}");
1540        assert!(xml.contains("<nSeqAdic>1</nSeqAdic>"));
1541        assert!(xml.contains("<nSeqAdic>2</nSeqAdic>"));
1542        assert!(xml.contains("<cFabricante>FAB_A</cFabricante>"));
1543        assert!(xml.contains("<cFabricante>FAB_B</cFabricante>"));
1544    }
1545
1546    #[test]
1547    fn di_in_det_xml_between_ind_tot_and_xped() {
1548        use crate::types::{AdiData, DiData};
1549
1550        let adi = AdiData::new("1", "FAB").n_adicao("001");
1551        let di = DiData::new(
1552            "DI-001",
1553            "2025-01-15",
1554            "Santos",
1555            "SP",
1556            "2025-01-20",
1557            "1",
1558            "1",
1559            "EXP",
1560            vec![adi],
1561        );
1562        let item = sample_item().di(vec![di]).x_ped("PO-123");
1563        let data = sample_build_data();
1564        let result = build_det(&item, &data).expect("build_det should succeed");
1565
1566        let xml = &result.xml;
1567        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1568        let di_pos = xml.find("<DI>").expect("<DI> must exist");
1569        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1570
1571        assert!(di_pos > ind_tot_pos, "DI must come after indTot");
1572        assert!(xped_pos > di_pos, "xPed must come after DI");
1573    }
1574
1575    // ── Detalhe de Exportação (detExport) ──────────────────────────────────
1576
1577    #[test]
1578    fn det_export_with_n_draw_only() {
1579        use crate::types::DetExportData;
1580
1581        let dex = DetExportData::new().n_draw("20250000001");
1582        let xml = build_det_export_xml(&dex);
1583
1584        assert_eq!(
1585            xml,
1586            "<detExport>\
1587                <nDraw>20250000001</nDraw>\
1588            </detExport>"
1589        );
1590    }
1591
1592    #[test]
1593    fn det_export_with_export_ind() {
1594        use crate::types::DetExportData;
1595
1596        let dex = DetExportData::new()
1597            .n_draw("20250000002")
1598            .n_re("123456789012")
1599            .ch_nfe("12345678901234567890123456789012345678901234")
1600            .q_export(100.5);
1601        let xml = build_det_export_xml(&dex);
1602
1603        assert_eq!(
1604            xml,
1605            "<detExport>\
1606                <nDraw>20250000002</nDraw>\
1607                <exportInd>\
1608                    <nRE>123456789012</nRE>\
1609                    <chNFe>12345678901234567890123456789012345678901234</chNFe>\
1610                    <qExport>100.5000</qExport>\
1611                </exportInd>\
1612            </detExport>"
1613        );
1614    }
1615
1616    #[test]
1617    fn det_export_empty() {
1618        use crate::types::DetExportData;
1619
1620        let dex = DetExportData::new();
1621        let xml = build_det_export_xml(&dex);
1622
1623        assert_eq!(xml, "<detExport></detExport>");
1624    }
1625
1626    #[test]
1627    fn det_export_in_det_xml_between_ind_tot_and_xped() {
1628        use crate::types::DetExportData;
1629
1630        let dex = DetExportData::new().n_draw("20250000001");
1631        let item = sample_item().det_export(vec![dex]).x_ped("PO-456");
1632        let data = sample_build_data();
1633        let result = build_det(&item, &data).expect("build_det should succeed");
1634
1635        let xml = &result.xml;
1636        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1637        let det_exp_pos = xml.find("<detExport>").expect("<detExport> must exist");
1638        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1639
1640        assert!(
1641            det_exp_pos > ind_tot_pos,
1642            "detExport must come after indTot"
1643        );
1644        assert!(xped_pos > det_exp_pos, "xPed must come after detExport");
1645    }
1646
1647    // ── Imposto Devolvido (impostoDevol) ───────────────────────────────────
1648
1649    #[test]
1650    fn imposto_devol_produces_correct_xml() {
1651        use crate::types::ImpostoDevolData;
1652
1653        let devol = ImpostoDevolData::new(Rate(10000), Cents(5000));
1654        let item = sample_item().imposto_devol(devol);
1655        let data = sample_build_data();
1656        let result = build_det(&item, &data).expect("build_det should succeed");
1657
1658        assert!(result.xml.contains(
1659            "<impostoDevol>\
1660                <pDevol>100.00</pDevol>\
1661                <IPI>\
1662                    <vIPIDevol>50.00</vIPIDevol>\
1663                </IPI>\
1664            </impostoDevol>"
1665        ));
1666        assert_eq!(result.v_ipi_devol, 5000);
1667    }
1668
1669    #[test]
1670    fn imposto_devol_50_percent() {
1671        use crate::types::ImpostoDevolData;
1672
1673        let devol = ImpostoDevolData::new(Rate(5000), Cents(2500));
1674        let item = sample_item().imposto_devol(devol);
1675        let data = sample_build_data();
1676        let result = build_det(&item, &data).expect("build_det should succeed");
1677
1678        assert!(result.xml.contains("<pDevol>50.00</pDevol>"));
1679        assert!(result.xml.contains("<vIPIDevol>25.00</vIPIDevol>"));
1680        assert_eq!(result.v_ipi_devol, 2500);
1681    }
1682
1683    #[test]
1684    fn imposto_devol_after_imposto_before_inf_ad_prod() {
1685        use crate::types::ImpostoDevolData;
1686
1687        let devol = ImpostoDevolData::new(Rate(10000), Cents(1000));
1688        let item = sample_item().imposto_devol(devol).inf_ad_prod("test info");
1689        let data = sample_build_data();
1690        let result = build_det(&item, &data).expect("build_det should succeed");
1691
1692        let imposto_end = result
1693            .xml
1694            .find("</imposto>")
1695            .expect("</imposto> must exist");
1696        let devol_pos = result
1697            .xml
1698            .find("<impostoDevol>")
1699            .expect("<impostoDevol> must exist");
1700        let inf_ad_pos = result
1701            .xml
1702            .find("<infAdProd>")
1703            .expect("<infAdProd> must exist");
1704
1705        assert!(
1706            devol_pos > imposto_end,
1707            "impostoDevol must come after </imposto>"
1708        );
1709        assert!(
1710            inf_ad_pos > devol_pos,
1711            "infAdProd must come after impostoDevol"
1712        );
1713    }
1714
1715    #[test]
1716    fn no_imposto_devol_when_none() {
1717        let item = sample_item();
1718        let data = sample_build_data();
1719        let result = build_det(&item, &data).expect("build_det should succeed");
1720
1721        assert!(!result.xml.contains("<impostoDevol>"));
1722        assert_eq!(result.v_ipi_devol, 0);
1723    }
1724}