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