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, vItem, DFeReferenciado)
582    // Deferred to after imposto assembly so we have access to computed values.
583
584    // Assemble imposto
585    let mut imposto_children: Vec<String> = Vec::new();
586    // vTotTrib: emitted as first child of <imposto> when > 0 (matches PHP tagimposto)
587    if let Some(ref v) = item.v_tot_trib {
588        if v.0 > 0 {
589            imposto_children.push(tag(
590                "vTotTrib",
591                &[],
592                TagContent::Text(&format_cents(v.0, 2)),
593            ));
594        }
595    }
596    if !icms_xml.is_empty() {
597        imposto_children.push(icms_xml);
598    }
599    if !ipi_xml.is_empty() {
600        imposto_children.push(ipi_xml);
601    }
602    // PIS or PISST (mutually exclusive per PHP sped-nfe)
603    if let Some(ref pis_st) = item.pis_st {
604        imposto_children.push(tax_pis_cofins_ipi::build_pis_st_xml(pis_st));
605    } else {
606        imposto_children.push(pis_xml);
607    }
608    // COFINS or COFINSST (mutually exclusive per PHP sped-nfe)
609    if let Some(ref cofins_st) = item.cofins_st {
610        imposto_children.push(tax_pis_cofins_ipi::build_cofins_st_xml(cofins_st));
611    } else {
612        imposto_children.push(cofins_xml);
613    }
614    if !ii_xml.is_empty() {
615        imposto_children.push(ii_xml);
616    }
617    if !issqn_xml.is_empty() {
618        imposto_children.push(issqn_xml);
619    }
620
621    // Build IS (Imposto Seletivo) -- optional, inside <imposto>
622    // Only emitted when schema is PL_010 or later (matching PHP: $this->schema > 9)
623    if data.schema_version.is_pl010() {
624        if let Some(ref is_data) = item.is_data {
625            imposto_children.push(tax_is::build_is_xml(is_data));
626        }
627
628        // Build IBS/CBS -- optional, inside <imposto>
629        if let Some(ref ibs_cbs_data) = item.ibs_cbs {
630            imposto_children.push(tax_ibs_cbs::build_ibs_cbs_xml(ibs_cbs_data));
631        }
632    }
633
634    // Assemble prod
635    let fc2 = |c: i64| format_cents(c, 2);
636    let fc10 = |c: i64| format_cents(c, 10);
637    let fd4 = |v: f64| format_decimal(v, 4);
638
639    let mut prod_children = vec![
640        tag("cProd", &[], TagContent::Text(&item.product_code)),
641        tag(
642            "cEAN",
643            &[],
644            TagContent::Text(item.c_ean.as_deref().unwrap_or("SEM GTIN")),
645        ),
646    ];
647    if let Some(ref cb) = item.c_barra {
648        prod_children.push(tag("cBarra", &[], TagContent::Text(cb)));
649    }
650    prod_children.push(tag(
651        "xProd",
652        &[],
653        TagContent::Text(
654            // PHP substitutes xProd for item 1 of NFC-e in homologation
655            if item.item_number == 1
656                && data.environment == SefazEnvironment::Homologation
657                && data.model == InvoiceModel::Nfce
658            {
659                HOMOLOGATION_XPROD
660            } else {
661                &item.description
662            },
663        ),
664    ));
665    prod_children.push(tag("NCM", &[], TagContent::Text(&item.ncm)));
666    for nve_code in &item.nve {
667        prod_children.push(tag("NVE", &[], TagContent::Text(nve_code)));
668    }
669    if let Some(ref cest) = item.cest {
670        prod_children.push(tag("CEST", &[], TagContent::Text(cest)));
671        if let Some(ref ind) = item.cest_ind_escala {
672            prod_children.push(tag("indEscala", &[], TagContent::Text(ind)));
673        }
674        if let Some(ref fab) = item.cest_cnpj_fab {
675            prod_children.push(tag("CNPJFab", &[], TagContent::Text(fab)));
676        }
677    }
678    if let Some(ref cb) = item.c_benef {
679        prod_children.push(tag("cBenef", &[], TagContent::Text(cb)));
680    }
681    // tpCredPresIBSZFM — PL_010 only, after cBenef, before gCred (NT 2025.002)
682    if data.schema_version.is_pl010() {
683        if let Some(ref tp) = item.tp_cred_pres_ibs_zfm {
684            prod_children.push(tag("tpCredPresIBSZFM", &[], TagContent::Text(tp)));
685        }
686    }
687    // gCred (crédito presumido ICMS) — up to 4 per item, inside <prod>
688    for gc in item.g_cred.iter().take(4) {
689        let p_str = format_rate4(gc.p_cred_presumido.0);
690        let mut gc_children = vec![
691            tag(
692                "cCredPresumido",
693                &[],
694                TagContent::Text(&gc.c_cred_presumido),
695            ),
696            tag("pCredPresumido", &[], TagContent::Text(&p_str)),
697        ];
698        if let Some(v) = gc.v_cred_presumido {
699            let v_str = format_cents(v.0, 2);
700            gc_children.push(tag("vCredPresumido", &[], TagContent::Text(&v_str)));
701        }
702        prod_children.push(tag("gCred", &[], TagContent::Children(gc_children)));
703    }
704    if let Some(ref ex) = item.extipi {
705        prod_children.push(tag("EXTIPI", &[], TagContent::Text(ex)));
706    }
707    prod_children.extend([
708        tag("CFOP", &[], TagContent::Text(&item.cfop)),
709        tag("uCom", &[], TagContent::Text(&item.unit_of_measure)),
710        tag("qCom", &[], TagContent::Text(&fd4(item.quantity))),
711        tag("vUnCom", &[], TagContent::Text(&fc10(item.unit_price.0))),
712        tag("vProd", &[], TagContent::Text(&fc2(item.total_price.0))),
713        tag(
714            "cEANTrib",
715            &[],
716            TagContent::Text(item.c_ean_trib.as_deref().unwrap_or("SEM GTIN")),
717        ),
718    ]);
719    if let Some(ref cbt) = item.c_barra_trib {
720        prod_children.push(tag("cBarraTrib", &[], TagContent::Text(cbt)));
721    }
722    let u_trib = item
723        .taxable_unit
724        .as_deref()
725        .unwrap_or(&item.unit_of_measure);
726    let q_trib = item.taxable_quantity.unwrap_or(item.quantity);
727    let v_un_trib = item
728        .taxable_unit_price
729        .map(|c| c.0)
730        .unwrap_or(item.unit_price.0);
731    prod_children.extend([
732        tag("uTrib", &[], TagContent::Text(u_trib)),
733        tag("qTrib", &[], TagContent::Text(&fd4(q_trib))),
734        tag("vUnTrib", &[], TagContent::Text(&fc10(v_un_trib))),
735    ]);
736    if let Some(v) = item.v_frete {
737        prod_children.push(tag("vFrete", &[], TagContent::Text(&fc2(v.0))));
738    }
739    if let Some(v) = item.v_seg {
740        prod_children.push(tag("vSeg", &[], TagContent::Text(&fc2(v.0))));
741    }
742    if let Some(v) = item.v_desc {
743        prod_children.push(tag("vDesc", &[], TagContent::Text(&fc2(v.0))));
744    }
745    if let Some(v) = item.v_outro {
746        prod_children.push(tag("vOutro", &[], TagContent::Text(&fc2(v.0))));
747    }
748    let ind_tot_str = match item.ind_tot {
749        Some(v) => v.to_string(),
750        None => "1".to_string(),
751    };
752    prod_children.push(tag("indTot", &[], TagContent::Text(&ind_tot_str)));
753    if item.ind_bem_movel_usado == Some(true) {
754        prod_children.push(tag("indBemMovelUsado", &[], TagContent::Text("1")));
755    }
756    // DI (Declaração de Importação) — after indTot, before detExport
757    if let Some(ref dis) = item.di {
758        for di in dis.iter().take(100) {
759            prod_children.push(build_di_xml(di));
760        }
761    }
762    // detExport — after DI, before xPed
763    if let Some(ref exports) = item.det_export {
764        for dex in exports.iter().take(500) {
765            prod_children.push(build_det_export_xml(dex));
766        }
767    }
768    if let Some(ref xped) = item.x_ped {
769        prod_children.push(tag("xPed", &[], TagContent::Text(xped)));
770    }
771    if let Some(ref nip) = item.n_item_ped {
772        prod_children.push(tag("nItemPed", &[], TagContent::Text(nip)));
773    }
774    if let Some(ref nfci) = item.n_fci {
775        prod_children.push(tag("nFCI", &[], TagContent::Text(nfci)));
776    }
777    prod_children.extend(prod_options);
778
779    // impostoDevol (after imposto, before infAdProd)
780    let mut v_ipi_devol = 0i64;
781    let imposto_devol_xml = if let Some(ref devol) = item.imposto_devol {
782        v_ipi_devol = devol.v_ipi_devol.0;
783        let p_devol_str = format_decimal(devol.p_devol.0 as f64 / 100.0, 2);
784        let v_ipi_devol_str = format_cents(devol.v_ipi_devol.0, 2);
785        tag(
786            "impostoDevol",
787            &[],
788            TagContent::Children(vec![
789                tag("pDevol", &[], TagContent::Text(&p_devol_str)),
790                tag(
791                    "IPI",
792                    &[],
793                    TagContent::Children(vec![tag(
794                        "vIPIDevol",
795                        &[],
796                        TagContent::Text(&v_ipi_devol_str),
797                    )]),
798                ),
799            ]),
800        )
801    } else {
802        String::new()
803    };
804
805    // Compute vItem for PL_010 — matching PHP calculateTtensValues2
806    // Emitted inside <det> when schema >= PL_010 and at least one item has IBS/CBS data.
807    let v_item_xml =
808        if data.schema_version.is_pl010() && data.items.iter().any(|i| i.ibs_cbs.is_some()) {
809            let v_item_cents = if let Some(ref explicit) = item.v_item {
810                // User-supplied vItem takes precedence (matches PHP: $this->aVItem[$item]['vItem'])
811                explicit.0
812            } else {
813                // Auto-calculate (matches PHP calculateTtensValues2)
814                let v_prod = item.total_price.0;
815                let v_desc = item.v_desc.map(|c| c.0).unwrap_or(0);
816                let v_icms_deson = if item_ind_deduz_deson {
817                    item.icms_v_icms_deson.map(|c| c.0).unwrap_or(0)
818                } else {
819                    0
820                };
821                let v_icms_st = icms_totals.v_st.0;
822                let v_icms_mono_reten = icms_totals.v_icms_mono_reten.0;
823                let v_fcp_st = icms_totals.v_fcp_st.0;
824                let v_frete = item.v_frete.map(|c| c.0).unwrap_or(0);
825                let v_seg = item.v_seg.map(|c| c.0).unwrap_or(0);
826                let v_outro = item.v_outro.map(|c| c.0).unwrap_or(0);
827
828                v_prod - v_desc - v_icms_deson
829                    + v_icms_st
830                    + v_icms_mono_reten
831                    + v_fcp_st
832                    + v_frete
833                    + v_seg
834                    + v_outro
835                    + v_ii
836                    + v_ipi
837                    + v_ipi_devol
838                    + v_pis_st
839                    + v_cofins_st
840            };
841            let v_item_str = format_cents(v_item_cents, 2);
842            tag("vItem", &[], TagContent::Text(&v_item_str))
843        } else {
844            String::new()
845        };
846
847    // Build det-level extras (infAdProd, obsItem, vItem, DFeReferenciado)
848    let det_extras = build_det_extras(item, &v_item_xml);
849
850    // Assemble det
851    let nitem = item.item_number.to_string();
852    let mut det_children = vec![
853        tag("prod", &[], TagContent::Children(prod_children)),
854        tag("imposto", &[], TagContent::Children(imposto_children)),
855    ];
856    if !imposto_devol_xml.is_empty() {
857        det_children.push(imposto_devol_xml);
858    }
859    det_children.extend(det_extras);
860
861    let xml = tag(
862        "det",
863        &[("nItem", &nitem)],
864        TagContent::Children(det_children),
865    );
866
867    Ok(DetResult {
868        xml,
869        icms_totals,
870        v_ipi,
871        v_pis: item.pis_v_pis.map(|c| c.0).unwrap_or(0),
872        v_cofins: item.cofins_v_cofins.map(|c| c.0).unwrap_or(0),
873        v_ii,
874        v_frete: item.v_frete.map(|c| c.0).unwrap_or(0),
875        v_seg: item.v_seg.map(|c| c.0).unwrap_or(0),
876        v_desc: item.v_desc.map(|c| c.0).unwrap_or(0),
877        v_outro: item.v_outro.map(|c| c.0).unwrap_or(0),
878        ind_tot: item.ind_tot.unwrap_or(1),
879        v_tot_trib: item.v_tot_trib.map(|c| c.0).unwrap_or(0),
880        v_ipi_devol,
881        v_pis_st,
882        v_cofins_st,
883        ind_deduz_deson: item_ind_deduz_deson,
884        has_issqn,
885    })
886}
887
888fn build_prod_options(item: &InvoiceItemData) -> Vec<String> {
889    let mut opts = Vec::new();
890
891    // rastro (batch tracking)
892    if let Some(ref rastros) = item.rastro {
893        for r in rastros.iter().take(500) {
894            let mut rastro_children = vec![
895                tag("nLote", &[], TagContent::Text(&r.n_lote)),
896                tag("qLote", &[], TagContent::Text(&format_decimal(r.q_lote, 3))),
897                tag("dFab", &[], TagContent::Text(&r.d_fab)),
898                tag("dVal", &[], TagContent::Text(&r.d_val)),
899            ];
900            if let Some(ref agreg) = r.c_agreg {
901                rastro_children.push(tag("cAgreg", &[], TagContent::Text(agreg)));
902            }
903            opts.push(tag("rastro", &[], TagContent::Children(rastro_children)));
904        }
905    }
906
907    // CHOICE group: veicProd, med, arma, nRECOPI (mutually exclusive)
908    if let Some(ref v) = item.veic_prod {
909        opts.push(tag(
910            "veicProd",
911            &[],
912            TagContent::Children(vec![
913                tag("tpOp", &[], TagContent::Text(&v.tp_op)),
914                tag("chassi", &[], TagContent::Text(&v.chassi)),
915                tag("cCor", &[], TagContent::Text(&v.c_cor)),
916                tag("xCor", &[], TagContent::Text(&v.x_cor)),
917                tag("pot", &[], TagContent::Text(&v.pot)),
918                tag("cilin", &[], TagContent::Text(&v.cilin)),
919                tag("pesoL", &[], TagContent::Text(&v.peso_l)),
920                tag("pesoB", &[], TagContent::Text(&v.peso_b)),
921                tag("nSerie", &[], TagContent::Text(&v.n_serie)),
922                tag("tpComb", &[], TagContent::Text(&v.tp_comb)),
923                tag("nMotor", &[], TagContent::Text(&v.n_motor)),
924                tag("CMT", &[], TagContent::Text(&v.cmt)),
925                tag("dist", &[], TagContent::Text(&v.dist)),
926                tag("anoMod", &[], TagContent::Text(&v.ano_mod)),
927                tag("anoFab", &[], TagContent::Text(&v.ano_fab)),
928                tag("tpPint", &[], TagContent::Text(&v.tp_pint)),
929                tag("tpVeic", &[], TagContent::Text(&v.tp_veic)),
930                tag("espVeic", &[], TagContent::Text(&v.esp_veic)),
931                tag("VIN", &[], TagContent::Text(&v.vin)),
932                tag("condVeic", &[], TagContent::Text(&v.cond_veic)),
933                tag("cMod", &[], TagContent::Text(&v.c_mod)),
934                tag("cCorDENATRAN", &[], TagContent::Text(&v.c_cor_denatran)),
935                tag("lota", &[], TagContent::Text(&v.lota)),
936                tag("tpRest", &[], TagContent::Text(&v.tp_rest)),
937            ]),
938        ));
939    } else if let Some(ref m) = item.med {
940        let mut med_children = Vec::new();
941        if let Some(ref code) = m.c_prod_anvisa {
942            med_children.push(tag("cProdANVISA", &[], TagContent::Text(code)));
943        }
944        if let Some(ref reason) = m.x_motivo_isencao {
945            med_children.push(tag("xMotivoIsencao", &[], TagContent::Text(reason)));
946        }
947        med_children.push(tag(
948            "vPMC",
949            &[],
950            TagContent::Text(&format_cents(m.v_pmc.0, 2)),
951        ));
952        opts.push(tag("med", &[], TagContent::Children(med_children)));
953    } else if let Some(ref arms) = item.arma {
954        for a in arms.iter().take(500) {
955            opts.push(tag(
956                "arma",
957                &[],
958                TagContent::Children(vec![
959                    tag("tpArma", &[], TagContent::Text(&a.tp_arma)),
960                    tag("nSerie", &[], TagContent::Text(&a.n_serie)),
961                    tag("nCano", &[], TagContent::Text(&a.n_cano)),
962                    tag("descr", &[], TagContent::Text(&a.descr)),
963                ]),
964            ));
965        }
966    } else if let Some(ref recopi) = item.n_recopi {
967        if !recopi.is_empty() {
968            opts.push(tag("nRECOPI", &[], TagContent::Text(recopi)));
969        }
970    }
971
972    // comb — fuel product data (after the CHOICE group, per NF-e schema order)
973    if let Some(ref comb) = item.comb {
974        opts.push(build_comb_xml(comb));
975    }
976
977    opts
978}
979
980/// Build a single `<DI>` element with its nested `<adi>` children.
981fn build_di_xml(di: &crate::types::DiData) -> String {
982    let mut children = vec![
983        tag("nDI", &[], TagContent::Text(&di.n_di)),
984        tag("dDI", &[], TagContent::Text(&di.d_di)),
985        tag("xLocDesemb", &[], TagContent::Text(&di.x_loc_desemb)),
986        tag("UFDesemb", &[], TagContent::Text(&di.uf_desemb)),
987        tag("dDesemb", &[], TagContent::Text(&di.d_desemb)),
988        tag("tpViaTransp", &[], TagContent::Text(&di.tp_via_transp)),
989    ];
990    if let Some(ref v) = di.v_afrmm {
991        children.push(tag("vAFRMM", &[], TagContent::Text(&format_cents(v.0, 2))));
992    }
993    children.push(tag(
994        "tpIntermedio",
995        &[],
996        TagContent::Text(&di.tp_intermedio),
997    ));
998    if let Some(ref cnpj) = di.cnpj {
999        children.push(tag("CNPJ", &[], TagContent::Text(cnpj)));
1000    } else if let Some(ref cpf) = di.cpf {
1001        children.push(tag("CPF", &[], TagContent::Text(cpf)));
1002    }
1003    if let Some(ref uf) = di.uf_terceiro {
1004        children.push(tag("UFTerceiro", &[], TagContent::Text(uf)));
1005    }
1006    children.push(tag("cExportador", &[], TagContent::Text(&di.c_exportador)));
1007    // adi children (up to 999 per DI)
1008    for adi in di.adi.iter().take(999) {
1009        let mut adi_children = Vec::new();
1010        if let Some(ref n) = adi.n_adicao {
1011            adi_children.push(tag("nAdicao", &[], TagContent::Text(n)));
1012        }
1013        adi_children.push(tag("nSeqAdic", &[], TagContent::Text(&adi.n_seq_adic)));
1014        adi_children.push(tag("cFabricante", &[], TagContent::Text(&adi.c_fabricante)));
1015        if let Some(ref v) = adi.v_desc_di {
1016            adi_children.push(tag("vDescDI", &[], TagContent::Text(&format_cents(v.0, 2))));
1017        }
1018        if let Some(ref n) = adi.n_draw {
1019            adi_children.push(tag("nDraw", &[], TagContent::Text(n)));
1020        }
1021        children.push(tag("adi", &[], TagContent::Children(adi_children)));
1022    }
1023    tag("DI", &[], TagContent::Children(children))
1024}
1025
1026/// Build a single `<detExport>` element with optional `<exportInd>`.
1027fn build_det_export_xml(dex: &crate::types::DetExportData) -> String {
1028    let mut children = Vec::new();
1029    if let Some(ref n) = dex.n_draw {
1030        children.push(tag("nDraw", &[], TagContent::Text(n)));
1031    }
1032    if dex.n_re.is_some() || dex.ch_nfe.is_some() || dex.q_export.is_some() {
1033        let mut exp_ind_children = Vec::new();
1034        if let Some(ref n) = dex.n_re {
1035            exp_ind_children.push(tag("nRE", &[], TagContent::Text(n)));
1036        }
1037        if let Some(ref ch) = dex.ch_nfe {
1038            exp_ind_children.push(tag("chNFe", &[], TagContent::Text(ch)));
1039        }
1040        if let Some(q) = dex.q_export {
1041            exp_ind_children.push(tag("qExport", &[], TagContent::Text(&format_decimal(q, 4))));
1042        }
1043        children.push(tag(
1044            "exportInd",
1045            &[],
1046            TagContent::Children(exp_ind_children),
1047        ));
1048    }
1049    tag("detExport", &[], TagContent::Children(children))
1050}
1051
1052/// Build the `<comb>` element for fuel products.
1053///
1054/// Follows the PHP sped-nfe `tagcomb` / `tagencerrante` / `tagorigComb`
1055/// structure exactly: cProdANP, descANP, pGLP, pGNn, pGNi, vPart, CODIF,
1056/// qTemp, UFCons, CIDE, encerrante, pBio, origComb[].
1057fn build_comb_xml(comb: &CombData) -> String {
1058    let mut children = vec![
1059        tag("cProdANP", &[], TagContent::Text(&comb.c_prod_anp)),
1060        tag("descANP", &[], TagContent::Text(&comb.desc_anp)),
1061    ];
1062
1063    if let Some(ref v) = comb.p_glp {
1064        children.push(tag("pGLP", &[], TagContent::Text(v)));
1065    }
1066    if let Some(ref v) = comb.p_gn_n {
1067        children.push(tag("pGNn", &[], TagContent::Text(v)));
1068    }
1069    if let Some(ref v) = comb.p_gn_i {
1070        children.push(tag("pGNi", &[], TagContent::Text(v)));
1071    }
1072    if let Some(ref v) = comb.v_part {
1073        children.push(tag("vPart", &[], TagContent::Text(v)));
1074    }
1075    if let Some(ref v) = comb.codif {
1076        children.push(tag("CODIF", &[], TagContent::Text(v)));
1077    }
1078    if let Some(ref v) = comb.q_temp {
1079        children.push(tag("qTemp", &[], TagContent::Text(v)));
1080    }
1081
1082    children.push(tag("UFCons", &[], TagContent::Text(&comb.uf_cons)));
1083
1084    // CIDE (conditional — only when qBCProd is present)
1085    if let Some(ref cide) = comb.cide {
1086        let cide_children = vec![
1087            tag("qBCProd", &[], TagContent::Text(&cide.q_bc_prod)),
1088            tag("vAliqProd", &[], TagContent::Text(&cide.v_aliq_prod)),
1089            tag("vCIDE", &[], TagContent::Text(&cide.v_cide)),
1090        ];
1091        children.push(tag("CIDE", &[], TagContent::Children(cide_children)));
1092    }
1093
1094    // encerrante
1095    if let Some(ref enc) = comb.encerrante {
1096        let mut enc_children = vec![tag("nBico", &[], TagContent::Text(&enc.n_bico))];
1097        if let Some(ref bomba) = enc.n_bomba {
1098            enc_children.push(tag("nBomba", &[], TagContent::Text(bomba)));
1099        }
1100        enc_children.push(tag("nTanque", &[], TagContent::Text(&enc.n_tanque)));
1101        enc_children.push(tag("vEncIni", &[], TagContent::Text(&enc.v_enc_ini)));
1102        enc_children.push(tag("vEncFin", &[], TagContent::Text(&enc.v_enc_fin)));
1103        children.push(tag("encerrante", &[], TagContent::Children(enc_children)));
1104    }
1105
1106    // pBio
1107    if let Some(ref v) = comb.p_bio {
1108        children.push(tag("pBio", &[], TagContent::Text(v)));
1109    }
1110
1111    // origComb (may be multiple)
1112    if let Some(ref origins) = comb.orig_comb {
1113        for orig in origins {
1114            let orig_children = vec![
1115                tag("indImport", &[], TagContent::Text(&orig.ind_import)),
1116                tag("cUFOrig", &[], TagContent::Text(&orig.c_uf_orig)),
1117                tag("pOrig", &[], TagContent::Text(&orig.p_orig)),
1118            ];
1119            children.push(tag("origComb", &[], TagContent::Children(orig_children)));
1120        }
1121    }
1122
1123    tag("comb", &[], TagContent::Children(children))
1124}
1125
1126fn build_det_extras(item: &InvoiceItemData, v_item_xml: &str) -> Vec<String> {
1127    let mut extras = Vec::new();
1128
1129    if let Some(ref info) = item.inf_ad_prod {
1130        extras.push(tag("infAdProd", &[], TagContent::Text(info)));
1131    }
1132
1133    if let Some(ref obs) = item.obs_item {
1134        let mut obs_children = Vec::new();
1135        if let Some(ref cont) = obs.obs_cont {
1136            obs_children.push(tag(
1137                "obsCont",
1138                &[("xCampo", &cont.x_campo)],
1139                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&cont.x_texto))]),
1140            ));
1141        }
1142        if let Some(ref fisco) = obs.obs_fisco {
1143            obs_children.push(tag(
1144                "obsFisco",
1145                &[("xCampo", &fisco.x_campo)],
1146                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&fisco.x_texto))]),
1147            ));
1148        }
1149        extras.push(tag("obsItem", &[], TagContent::Children(obs_children)));
1150    }
1151
1152    // vItem — PL_010 only, after obsItem, before DFeReferenciado
1153    if !v_item_xml.is_empty() {
1154        extras.push(v_item_xml.to_string());
1155    }
1156
1157    if let Some(ref dfe) = item.dfe_referenciado {
1158        let mut dfe_children = vec![tag("chaveAcesso", &[], TagContent::Text(&dfe.chave_acesso))];
1159        if let Some(ref n) = dfe.n_item {
1160            dfe_children.push(tag("nItem", &[], TagContent::Text(n)));
1161        }
1162        extras.push(tag(
1163            "DFeReferenciado",
1164            &[],
1165            TagContent::Children(dfe_children),
1166        ));
1167    }
1168
1169    extras
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use crate::newtypes::{Cents, IbgeCode, Rate, Rate4};
1176    use crate::tax_issqn::IssqnData as TaxIssqnData;
1177    use crate::types::{
1178        ArmaData, CideData, CombData, EncerranteData, GCredData, InvoiceItemData, InvoiceModel,
1179        IssuerData, MedData, OrigCombData, RastroData, SefazEnvironment, TaxRegime, VeicProdData,
1180    };
1181
1182    fn sample_build_data() -> InvoiceBuildData {
1183        let issuer = IssuerData::new(
1184            "12345678000199",
1185            "123456789",
1186            "Test Company",
1187            TaxRegime::SimplesNacional,
1188            "SP",
1189            IbgeCode("3550308".to_string()),
1190            "Sao Paulo",
1191            "Av Paulista",
1192            "1000",
1193            "Bela Vista",
1194            "01310100",
1195        );
1196
1197        InvoiceBuildData {
1198            schema_version: crate::types::SchemaVersion::PL009,
1199            model: InvoiceModel::Nfe,
1200            series: 1,
1201            number: 1,
1202            emission_type: crate::types::EmissionType::Normal,
1203            environment: SefazEnvironment::Homologation,
1204            issued_at: chrono::Utc::now()
1205                .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).expect("valid offset")),
1206            operation_nature: "VENDA".to_string(),
1207            issuer,
1208            recipient: None,
1209            items: Vec::new(),
1210            payments: Vec::new(),
1211            change_amount: None,
1212            payment_card_details: None,
1213            contingency: None,
1214            exit_at: None,
1215            operation_type: None,
1216            purpose_code: None,
1217            intermediary_indicator: None,
1218            emission_process: None,
1219            consumer_type: None,
1220            buyer_presence: None,
1221            print_format: None,
1222            references: None,
1223            transport: None,
1224            billing: None,
1225            withdrawal: None,
1226            delivery: None,
1227            authorized_xml: None,
1228            additional_info: None,
1229            intermediary: None,
1230            ret_trib: None,
1231            tech_responsible: None,
1232            purchase: None,
1233            export: None,
1234            issqn_tot: None,
1235            cana: None,
1236            agropecuario: None,
1237            compra_gov: None,
1238            pag_antecipado: None,
1239            is_tot: None,
1240            ibs_cbs_tot: None,
1241            v_nf_tot_override: None,
1242            destination_indicator: None,
1243            ver_proc: None,
1244            only_ascii: false,
1245            calculation_method: crate::types::CalculationMethod::V2,
1246        }
1247    }
1248
1249    fn sample_item() -> InvoiceItemData {
1250        InvoiceItemData::new(
1251            1,
1252            "001",
1253            "Gasolina Comum",
1254            "27101259",
1255            "5102",
1256            "LT",
1257            50.0,
1258            Cents(599),
1259            Cents(29950),
1260            "102",
1261            Rate(0),
1262            Cents(0),
1263            "99",
1264            "99",
1265        )
1266    }
1267
1268    // ── Combustíveis ────────────────────────────────────────────────────────
1269
1270    #[test]
1271    fn comb_minimal_produces_correct_xml() {
1272        let comb = CombData::new("210203001", "GLP", "SP");
1273        let xml = build_comb_xml(&comb);
1274
1275        assert_eq!(
1276            xml,
1277            "<comb>\
1278                <cProdANP>210203001</cProdANP>\
1279                <descANP>GLP</descANP>\
1280                <UFCons>SP</UFCons>\
1281            </comb>"
1282        );
1283    }
1284
1285    #[test]
1286    fn comb_with_glp_percentages() {
1287        let comb = CombData::new("210203001", "GLP", "SP")
1288            .p_glp("60.0000")
1289            .p_gn_n("25.0000")
1290            .p_gn_i("15.0000")
1291            .v_part("3.50");
1292
1293        let xml = build_comb_xml(&comb);
1294
1295        assert_eq!(
1296            xml,
1297            "<comb>\
1298                <cProdANP>210203001</cProdANP>\
1299                <descANP>GLP</descANP>\
1300                <pGLP>60.0000</pGLP>\
1301                <pGNn>25.0000</pGNn>\
1302                <pGNi>15.0000</pGNi>\
1303                <vPart>3.50</vPart>\
1304                <UFCons>SP</UFCons>\
1305            </comb>"
1306        );
1307    }
1308
1309    #[test]
1310    fn comb_with_codif_and_qtemp() {
1311        let comb = CombData::new("320102001", "GASOLINA COMUM", "PR")
1312            .codif("123456789")
1313            .q_temp("1000.0000");
1314
1315        let xml = build_comb_xml(&comb);
1316
1317        assert_eq!(
1318            xml,
1319            "<comb>\
1320                <cProdANP>320102001</cProdANP>\
1321                <descANP>GASOLINA COMUM</descANP>\
1322                <CODIF>123456789</CODIF>\
1323                <qTemp>1000.0000</qTemp>\
1324                <UFCons>PR</UFCons>\
1325            </comb>"
1326        );
1327    }
1328
1329    #[test]
1330    fn comb_with_cide() {
1331        let cide = CideData::new("1000.0000", "0.0700", "70.00");
1332        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").cide(cide);
1333
1334        let xml = build_comb_xml(&comb);
1335
1336        assert_eq!(
1337            xml,
1338            "<comb>\
1339                <cProdANP>320102001</cProdANP>\
1340                <descANP>GASOLINA COMUM</descANP>\
1341                <UFCons>SP</UFCons>\
1342                <CIDE>\
1343                    <qBCProd>1000.0000</qBCProd>\
1344                    <vAliqProd>0.0700</vAliqProd>\
1345                    <vCIDE>70.00</vCIDE>\
1346                </CIDE>\
1347            </comb>"
1348        );
1349    }
1350
1351    #[test]
1352    fn comb_with_encerrante() {
1353        let enc = EncerranteData::new("1", "1", "1234.567", "1284.567").n_bomba("2");
1354        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").encerrante(enc);
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                <encerrante>\
1365                    <nBico>1</nBico>\
1366                    <nBomba>2</nBomba>\
1367                    <nTanque>1</nTanque>\
1368                    <vEncIni>1234.567</vEncIni>\
1369                    <vEncFin>1284.567</vEncFin>\
1370                </encerrante>\
1371            </comb>"
1372        );
1373    }
1374
1375    #[test]
1376    fn comb_encerrante_without_bomba() {
1377        let enc = EncerranteData::new("3", "2", "5000.000", "5050.000");
1378        let comb = CombData::new("320102001", "GASOLINA COMUM", "RJ").encerrante(enc);
1379
1380        let xml = build_comb_xml(&comb);
1381
1382        assert_eq!(
1383            xml,
1384            "<comb>\
1385                <cProdANP>320102001</cProdANP>\
1386                <descANP>GASOLINA COMUM</descANP>\
1387                <UFCons>RJ</UFCons>\
1388                <encerrante>\
1389                    <nBico>3</nBico>\
1390                    <nTanque>2</nTanque>\
1391                    <vEncIni>5000.000</vEncIni>\
1392                    <vEncFin>5050.000</vEncFin>\
1393                </encerrante>\
1394            </comb>"
1395        );
1396    }
1397
1398    #[test]
1399    fn comb_with_pbio() {
1400        let comb = CombData::new("810102001", "OLEO DIESEL B S10", "SP").p_bio("15.0000");
1401
1402        let xml = build_comb_xml(&comb);
1403
1404        assert_eq!(
1405            xml,
1406            "<comb>\
1407                <cProdANP>810102001</cProdANP>\
1408                <descANP>OLEO DIESEL B S10</descANP>\
1409                <UFCons>SP</UFCons>\
1410                <pBio>15.0000</pBio>\
1411            </comb>"
1412        );
1413    }
1414
1415    #[test]
1416    fn comb_with_orig_comb_single() {
1417        let orig = OrigCombData::new("0", "35", "100.0000");
1418        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig]);
1419
1420        let xml = build_comb_xml(&comb);
1421
1422        assert_eq!(
1423            xml,
1424            "<comb>\
1425                <cProdANP>320102001</cProdANP>\
1426                <descANP>GASOLINA COMUM</descANP>\
1427                <UFCons>SP</UFCons>\
1428                <origComb>\
1429                    <indImport>0</indImport>\
1430                    <cUFOrig>35</cUFOrig>\
1431                    <pOrig>100.0000</pOrig>\
1432                </origComb>\
1433            </comb>"
1434        );
1435    }
1436
1437    #[test]
1438    fn comb_with_orig_comb_multiple() {
1439        let orig1 = OrigCombData::new("0", "35", "70.0000");
1440        let orig2 = OrigCombData::new("1", "99", "30.0000");
1441        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig1, orig2]);
1442
1443        let xml = build_comb_xml(&comb);
1444
1445        assert_eq!(
1446            xml,
1447            "<comb>\
1448                <cProdANP>320102001</cProdANP>\
1449                <descANP>GASOLINA COMUM</descANP>\
1450                <UFCons>SP</UFCons>\
1451                <origComb>\
1452                    <indImport>0</indImport>\
1453                    <cUFOrig>35</cUFOrig>\
1454                    <pOrig>70.0000</pOrig>\
1455                </origComb>\
1456                <origComb>\
1457                    <indImport>1</indImport>\
1458                    <cUFOrig>99</cUFOrig>\
1459                    <pOrig>30.0000</pOrig>\
1460                </origComb>\
1461            </comb>"
1462        );
1463    }
1464
1465    #[test]
1466    fn comb_full_with_all_fields() {
1467        let cide = CideData::new("500.0000", "0.0700", "35.00");
1468        let enc = EncerranteData::new("1", "1", "10000.000", "10050.000").n_bomba("1");
1469        let orig = OrigCombData::new("0", "35", "100.0000");
1470
1471        let comb = CombData::new("210203001", "GLP", "SP")
1472            .p_glp("60.0000")
1473            .p_gn_n("25.0000")
1474            .p_gn_i("15.0000")
1475            .v_part("3.50")
1476            .codif("999888777")
1477            .q_temp("500.0000")
1478            .cide(cide)
1479            .encerrante(enc)
1480            .p_bio("12.0000")
1481            .orig_comb(vec![orig]);
1482
1483        let xml = build_comb_xml(&comb);
1484
1485        assert_eq!(
1486            xml,
1487            "<comb>\
1488                <cProdANP>210203001</cProdANP>\
1489                <descANP>GLP</descANP>\
1490                <pGLP>60.0000</pGLP>\
1491                <pGNn>25.0000</pGNn>\
1492                <pGNi>15.0000</pGNi>\
1493                <vPart>3.50</vPart>\
1494                <CODIF>999888777</CODIF>\
1495                <qTemp>500.0000</qTemp>\
1496                <UFCons>SP</UFCons>\
1497                <CIDE>\
1498                    <qBCProd>500.0000</qBCProd>\
1499                    <vAliqProd>0.0700</vAliqProd>\
1500                    <vCIDE>35.00</vCIDE>\
1501                </CIDE>\
1502                <encerrante>\
1503                    <nBico>1</nBico>\
1504                    <nBomba>1</nBomba>\
1505                    <nTanque>1</nTanque>\
1506                    <vEncIni>10000.000</vEncIni>\
1507                    <vEncFin>10050.000</vEncFin>\
1508                </encerrante>\
1509                <pBio>12.0000</pBio>\
1510                <origComb>\
1511                    <indImport>0</indImport>\
1512                    <cUFOrig>35</cUFOrig>\
1513                    <pOrig>100.0000</pOrig>\
1514                </origComb>\
1515            </comb>"
1516        );
1517    }
1518
1519    #[test]
1520    fn comb_in_det_xml() {
1521        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP");
1522        let item = sample_item().comb(comb);
1523        let data = sample_build_data();
1524        let result = build_det(&item, &data).expect("build_det should succeed");
1525
1526        // <comb> appears inside <prod>
1527        let prod_start = result.xml.find("<prod>").expect("<prod> must exist");
1528        let prod_end = result.xml.find("</prod>").expect("</prod> must exist");
1529        let prod_section = &result.xml[prod_start..prod_end];
1530
1531        assert!(prod_section.contains("<comb>"));
1532        assert!(prod_section.contains("<cProdANP>320102001</cProdANP>"));
1533        assert!(prod_section.contains("<descANP>GASOLINA COMUM</descANP>"));
1534        assert!(prod_section.contains("<UFCons>SP</UFCons>"));
1535        assert!(prod_section.contains("</comb>"));
1536    }
1537
1538    // ── ISSQN ───────────────────────────────────────────────────────────────
1539
1540    #[test]
1541    fn issqn_item_produces_issqn_tag_not_icms() {
1542        let issqn_data = TaxIssqnData::new(10000, 500, 500, "3550308", "14.01")
1543            .ind_iss("1")
1544            .ind_incentivo("2");
1545        let item = sample_item().issqn(issqn_data);
1546        let data = sample_build_data();
1547        let result = build_det(&item, &data).expect("build_det should succeed");
1548
1549        // ISSQN tag present inside <imposto>
1550        assert!(result.xml.contains("<ISSQN>"));
1551        assert!(result.xml.contains("<vBC>100.00</vBC>"));
1552        assert!(result.xml.contains("<vAliq>5.0000</vAliq>"));
1553        assert!(result.xml.contains("<vISSQN>5.00</vISSQN>"));
1554        assert!(result.xml.contains("<cMunFG>3550308</cMunFG>"));
1555        assert!(result.xml.contains("<cListServ>14.01</cListServ>"));
1556        assert!(result.xml.contains("<indISS>1</indISS>"));
1557        assert!(result.xml.contains("<indIncentivo>2</indIncentivo>"));
1558        assert!(result.xml.contains("</ISSQN>"));
1559
1560        // ICMS should NOT be present for ISSQN items
1561        assert!(!result.xml.contains("<ICMS>"));
1562        assert!(!result.xml.contains("</ICMS>"));
1563        assert!(result.has_issqn);
1564    }
1565
1566    #[test]
1567    fn issqn_item_with_all_optional_fields() {
1568        let issqn_data = TaxIssqnData::new(20000, 300, 600, "3304557", "07.02")
1569            .v_deducao(1000)
1570            .v_outro(500)
1571            .v_desc_incond(200)
1572            .v_desc_cond(100)
1573            .v_iss_ret(300)
1574            .ind_iss("1")
1575            .c_servico("1234")
1576            .c_mun("3304557")
1577            .c_pais("1058")
1578            .n_processo("ABC123")
1579            .ind_incentivo("1");
1580
1581        let item = sample_item().issqn(issqn_data);
1582        let data = sample_build_data();
1583        let result = build_det(&item, &data).expect("build_det should succeed");
1584
1585        assert!(result.xml.contains("<vBC>200.00</vBC>"));
1586        assert!(result.xml.contains("<vAliq>3.0000</vAliq>"));
1587        assert!(result.xml.contains("<vISSQN>6.00</vISSQN>"));
1588        assert!(result.xml.contains("<vDeducao>10.00</vDeducao>"));
1589        assert!(result.xml.contains("<vOutro>5.00</vOutro>"));
1590        assert!(result.xml.contains("<vDescIncond>2.00</vDescIncond>"));
1591        assert!(result.xml.contains("<vDescCond>1.00</vDescCond>"));
1592        assert!(result.xml.contains("<vISSRet>3.00</vISSRet>"));
1593        assert!(result.xml.contains("<cServico>1234</cServico>"));
1594        assert!(result.xml.contains("<cMun>3304557</cMun>"));
1595        assert!(result.xml.contains("<cPais>1058</cPais>"));
1596        assert!(result.xml.contains("<nProcesso>ABC123</nProcesso>"));
1597        assert!(result.xml.contains("<indIncentivo>1</indIncentivo>"));
1598        assert!(result.has_issqn);
1599    }
1600
1601    #[test]
1602    fn non_issqn_item_has_icms_and_no_issqn() {
1603        let item = sample_item();
1604        let data = sample_build_data();
1605        let result = build_det(&item, &data).expect("build_det should succeed");
1606
1607        assert!(result.xml.contains("<ICMS"));
1608        assert!(!result.xml.contains("<ISSQN>"));
1609        assert!(!result.has_issqn);
1610    }
1611
1612    // ── Declaração de Importação (DI) ──────────────────────────────────────
1613
1614    #[test]
1615    fn di_minimal_with_one_adi() {
1616        use crate::types::{AdiData, DiData};
1617
1618        let adi = AdiData::new("1", "FABRICANTE_X").n_adicao("001");
1619        let di = DiData::new(
1620            "1234567890",
1621            "2025-01-15",
1622            "Santos",
1623            "SP",
1624            "2025-01-20",
1625            "1",
1626            "1",
1627            "EXP001",
1628            vec![adi],
1629        );
1630        let xml = build_di_xml(&di);
1631
1632        assert_eq!(
1633            xml,
1634            "<DI>\
1635                <nDI>1234567890</nDI>\
1636                <dDI>2025-01-15</dDI>\
1637                <xLocDesemb>Santos</xLocDesemb>\
1638                <UFDesemb>SP</UFDesemb>\
1639                <dDesemb>2025-01-20</dDesemb>\
1640                <tpViaTransp>1</tpViaTransp>\
1641                <tpIntermedio>1</tpIntermedio>\
1642                <cExportador>EXP001</cExportador>\
1643                <adi>\
1644                    <nAdicao>001</nAdicao>\
1645                    <nSeqAdic>1</nSeqAdic>\
1646                    <cFabricante>FABRICANTE_X</cFabricante>\
1647                </adi>\
1648            </DI>"
1649        );
1650    }
1651
1652    #[test]
1653    fn di_with_all_optional_fields() {
1654        use crate::types::{AdiData, DiData};
1655
1656        let adi = AdiData::new("1", "FAB_Y")
1657            .n_adicao("002")
1658            .v_desc_di(Cents(15000))
1659            .n_draw("20259999999");
1660        let di = DiData::new(
1661            "DI-2025-001",
1662            "2025-03-01",
1663            "Paranagua",
1664            "PR",
1665            "2025-03-05",
1666            "1",
1667            "2",
1668            "EXP002",
1669            vec![adi],
1670        )
1671        .v_afrmm(Cents(5000))
1672        .cnpj("12345678000199")
1673        .uf_terceiro("RJ");
1674
1675        let xml = build_di_xml(&di);
1676
1677        assert_eq!(
1678            xml,
1679            "<DI>\
1680                <nDI>DI-2025-001</nDI>\
1681                <dDI>2025-03-01</dDI>\
1682                <xLocDesemb>Paranagua</xLocDesemb>\
1683                <UFDesemb>PR</UFDesemb>\
1684                <dDesemb>2025-03-05</dDesemb>\
1685                <tpViaTransp>1</tpViaTransp>\
1686                <vAFRMM>50.00</vAFRMM>\
1687                <tpIntermedio>2</tpIntermedio>\
1688                <CNPJ>12345678000199</CNPJ>\
1689                <UFTerceiro>RJ</UFTerceiro>\
1690                <cExportador>EXP002</cExportador>\
1691                <adi>\
1692                    <nAdicao>002</nAdicao>\
1693                    <nSeqAdic>1</nSeqAdic>\
1694                    <cFabricante>FAB_Y</cFabricante>\
1695                    <vDescDI>150.00</vDescDI>\
1696                    <nDraw>20259999999</nDraw>\
1697                </adi>\
1698            </DI>"
1699        );
1700    }
1701
1702    #[test]
1703    fn di_with_cpf_instead_of_cnpj() {
1704        use crate::types::{AdiData, DiData};
1705
1706        let adi = AdiData::new("1", "FAB_Z");
1707        let di = DiData::new(
1708            "DI-CPF",
1709            "2025-06-01",
1710            "Recife",
1711            "PE",
1712            "2025-06-03",
1713            "7",
1714            "3",
1715            "EXP003",
1716            vec![adi],
1717        )
1718        .cpf("12345678901");
1719
1720        let xml = build_di_xml(&di);
1721        assert!(xml.contains("<CPF>12345678901</CPF>"));
1722        assert!(!xml.contains("<CNPJ>"));
1723    }
1724
1725    #[test]
1726    fn di_with_multiple_adi() {
1727        use crate::types::{AdiData, DiData};
1728
1729        let adi1 = AdiData::new("1", "FAB_A").n_adicao("001");
1730        let adi2 = AdiData::new("2", "FAB_B").n_adicao("001");
1731        let di = DiData::new(
1732            "DI-MULTI",
1733            "2025-01-01",
1734            "Santos",
1735            "SP",
1736            "2025-01-05",
1737            "1",
1738            "1",
1739            "EXP-M",
1740            vec![adi1, adi2],
1741        );
1742        let xml = build_di_xml(&di);
1743
1744        // Both adi elements present
1745        let count = xml.matches("<adi>").count();
1746        assert_eq!(count, 2, "expected 2 <adi> elements, got {count}");
1747        assert!(xml.contains("<nSeqAdic>1</nSeqAdic>"));
1748        assert!(xml.contains("<nSeqAdic>2</nSeqAdic>"));
1749        assert!(xml.contains("<cFabricante>FAB_A</cFabricante>"));
1750        assert!(xml.contains("<cFabricante>FAB_B</cFabricante>"));
1751    }
1752
1753    #[test]
1754    fn di_in_det_xml_between_ind_tot_and_xped() {
1755        use crate::types::{AdiData, DiData};
1756
1757        let adi = AdiData::new("1", "FAB").n_adicao("001");
1758        let di = DiData::new(
1759            "DI-001",
1760            "2025-01-15",
1761            "Santos",
1762            "SP",
1763            "2025-01-20",
1764            "1",
1765            "1",
1766            "EXP",
1767            vec![adi],
1768        );
1769        let item = sample_item().di(vec![di]).x_ped("PO-123");
1770        let data = sample_build_data();
1771        let result = build_det(&item, &data).expect("build_det should succeed");
1772
1773        let xml = &result.xml;
1774        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1775        let di_pos = xml.find("<DI>").expect("<DI> must exist");
1776        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1777
1778        assert!(di_pos > ind_tot_pos, "DI must come after indTot");
1779        assert!(xped_pos > di_pos, "xPed must come after DI");
1780    }
1781
1782    // ── Detalhe de Exportação (detExport) ──────────────────────────────────
1783
1784    #[test]
1785    fn det_export_with_n_draw_only() {
1786        use crate::types::DetExportData;
1787
1788        let dex = DetExportData::new().n_draw("20250000001");
1789        let xml = build_det_export_xml(&dex);
1790
1791        assert_eq!(
1792            xml,
1793            "<detExport>\
1794                <nDraw>20250000001</nDraw>\
1795            </detExport>"
1796        );
1797    }
1798
1799    #[test]
1800    fn det_export_with_export_ind() {
1801        use crate::types::DetExportData;
1802
1803        let dex = DetExportData::new()
1804            .n_draw("20250000002")
1805            .n_re("123456789012")
1806            .ch_nfe("12345678901234567890123456789012345678901234")
1807            .q_export(100.5);
1808        let xml = build_det_export_xml(&dex);
1809
1810        assert_eq!(
1811            xml,
1812            "<detExport>\
1813                <nDraw>20250000002</nDraw>\
1814                <exportInd>\
1815                    <nRE>123456789012</nRE>\
1816                    <chNFe>12345678901234567890123456789012345678901234</chNFe>\
1817                    <qExport>100.5000</qExport>\
1818                </exportInd>\
1819            </detExport>"
1820        );
1821    }
1822
1823    #[test]
1824    fn det_export_empty() {
1825        use crate::types::DetExportData;
1826
1827        let dex = DetExportData::new();
1828        let xml = build_det_export_xml(&dex);
1829
1830        assert_eq!(xml, "<detExport></detExport>");
1831    }
1832
1833    #[test]
1834    fn det_export_in_det_xml_between_ind_tot_and_xped() {
1835        use crate::types::DetExportData;
1836
1837        let dex = DetExportData::new().n_draw("20250000001");
1838        let item = sample_item().det_export(vec![dex]).x_ped("PO-456");
1839        let data = sample_build_data();
1840        let result = build_det(&item, &data).expect("build_det should succeed");
1841
1842        let xml = &result.xml;
1843        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1844        let det_exp_pos = xml.find("<detExport>").expect("<detExport> must exist");
1845        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1846
1847        assert!(
1848            det_exp_pos > ind_tot_pos,
1849            "detExport must come after indTot"
1850        );
1851        assert!(xped_pos > det_exp_pos, "xPed must come after detExport");
1852    }
1853
1854    // ── Imposto Devolvido (impostoDevol) ───────────────────────────────────
1855
1856    #[test]
1857    fn imposto_devol_produces_correct_xml() {
1858        use crate::types::ImpostoDevolData;
1859
1860        let devol = ImpostoDevolData::new(Rate(10000), Cents(5000));
1861        let item = sample_item().imposto_devol(devol);
1862        let data = sample_build_data();
1863        let result = build_det(&item, &data).expect("build_det should succeed");
1864
1865        assert!(result.xml.contains(
1866            "<impostoDevol>\
1867                <pDevol>100.00</pDevol>\
1868                <IPI>\
1869                    <vIPIDevol>50.00</vIPIDevol>\
1870                </IPI>\
1871            </impostoDevol>"
1872        ));
1873        assert_eq!(result.v_ipi_devol, 5000);
1874    }
1875
1876    #[test]
1877    fn imposto_devol_50_percent() {
1878        use crate::types::ImpostoDevolData;
1879
1880        let devol = ImpostoDevolData::new(Rate(5000), Cents(2500));
1881        let item = sample_item().imposto_devol(devol);
1882        let data = sample_build_data();
1883        let result = build_det(&item, &data).expect("build_det should succeed");
1884
1885        assert!(result.xml.contains("<pDevol>50.00</pDevol>"));
1886        assert!(result.xml.contains("<vIPIDevol>25.00</vIPIDevol>"));
1887        assert_eq!(result.v_ipi_devol, 2500);
1888    }
1889
1890    #[test]
1891    fn imposto_devol_after_imposto_before_inf_ad_prod() {
1892        use crate::types::ImpostoDevolData;
1893
1894        let devol = ImpostoDevolData::new(Rate(10000), Cents(1000));
1895        let item = sample_item().imposto_devol(devol).inf_ad_prod("test info");
1896        let data = sample_build_data();
1897        let result = build_det(&item, &data).expect("build_det should succeed");
1898
1899        let imposto_end = result
1900            .xml
1901            .find("</imposto>")
1902            .expect("</imposto> must exist");
1903        let devol_pos = result
1904            .xml
1905            .find("<impostoDevol>")
1906            .expect("<impostoDevol> must exist");
1907        let inf_ad_pos = result
1908            .xml
1909            .find("<infAdProd>")
1910            .expect("<infAdProd> must exist");
1911
1912        assert!(
1913            devol_pos > imposto_end,
1914            "impostoDevol must come after </imposto>"
1915        );
1916        assert!(
1917            inf_ad_pos > devol_pos,
1918            "infAdProd must come after impostoDevol"
1919        );
1920    }
1921
1922    #[test]
1923    fn no_imposto_devol_when_none() {
1924        let item = sample_item();
1925        let data = sample_build_data();
1926        let result = build_det(&item, &data).expect("build_det should succeed");
1927
1928        assert!(!result.xml.contains("<impostoDevol>"));
1929        assert_eq!(result.v_ipi_devol, 0);
1930    }
1931
1932    // ── NVE (Nomenclatura de Valor Aduaneiro e Estatística) ──────────────
1933
1934    #[test]
1935    fn nve_single_code_produces_correct_xml() {
1936        let item = sample_item().nve("AA0001");
1937        let data = sample_build_data();
1938        let result = build_det(&item, &data).expect("build_det should succeed");
1939
1940        assert!(result.xml.contains("<NVE>AA0001</NVE>"));
1941        // NVE must appear after NCM
1942        let ncm_pos = result.xml.find("<NCM>").expect("<NCM> must exist");
1943        let nve_pos = result
1944            .xml
1945            .find("<NVE>AA0001</NVE>")
1946            .expect("<NVE> must exist");
1947        assert!(nve_pos > ncm_pos, "NVE must come after NCM");
1948    }
1949
1950    #[test]
1951    fn nve_multiple_codes_produces_correct_xml() {
1952        let item = sample_item().nve("AA0001").nve("BB0002").nve("CC0003");
1953        let data = sample_build_data();
1954        let result = build_det(&item, &data).expect("build_det should succeed");
1955
1956        assert!(result.xml.contains("<NVE>AA0001</NVE>"));
1957        assert!(result.xml.contains("<NVE>BB0002</NVE>"));
1958        assert!(result.xml.contains("<NVE>CC0003</NVE>"));
1959        // Verify order: AA0001 before BB0002 before CC0003
1960        let pos_a = result.xml.find("<NVE>AA0001</NVE>").expect("AA0001");
1961        let pos_b = result.xml.find("<NVE>BB0002</NVE>").expect("BB0002");
1962        let pos_c = result.xml.find("<NVE>CC0003</NVE>").expect("CC0003");
1963        assert!(pos_a < pos_b, "NVE codes must preserve insertion order");
1964        assert!(pos_b < pos_c, "NVE codes must preserve insertion order");
1965    }
1966
1967    #[test]
1968    fn nve_eight_codes_is_valid() {
1969        let item = sample_item()
1970            .nve("AA0001")
1971            .nve("AA0002")
1972            .nve("AA0003")
1973            .nve("AA0004")
1974            .nve("AA0005")
1975            .nve("AA0006")
1976            .nve("AA0007")
1977            .nve("AA0008");
1978        let data = sample_build_data();
1979        let result = build_det(&item, &data);
1980        assert!(result.is_ok(), "8 NVE codes should be valid");
1981        let xml = result.expect("valid").xml;
1982        assert_eq!(xml.matches("<NVE>").count(), 8);
1983    }
1984
1985    #[test]
1986    fn nve_nine_codes_returns_error() {
1987        let item = sample_item()
1988            .nve("AA0001")
1989            .nve("AA0002")
1990            .nve("AA0003")
1991            .nve("AA0004")
1992            .nve("AA0005")
1993            .nve("AA0006")
1994            .nve("AA0007")
1995            .nve("AA0008")
1996            .nve("AA0009");
1997        let data = sample_build_data();
1998        let result = build_det(&item, &data);
1999        assert!(result.is_err(), "9 NVE codes should be rejected");
2000        let err = result.unwrap_err();
2001        assert_eq!(
2002            err,
2003            FiscalError::InvalidTaxData("Item 1: NVE limited to 8 entries, got 9".to_string())
2004        );
2005    }
2006
2007    #[test]
2008    fn nve_empty_vec_produces_no_nve_tags() {
2009        let item = sample_item();
2010        let data = sample_build_data();
2011        let result = build_det(&item, &data).expect("build_det should succeed");
2012
2013        assert!(!result.xml.contains("<NVE>"));
2014    }
2015
2016    #[test]
2017    fn nve_appears_before_cest() {
2018        let item = sample_item().nve("AA0001").cest("1234567");
2019        let data = sample_build_data();
2020        let result = build_det(&item, &data).expect("build_det should succeed");
2021
2022        let nve_pos = result
2023            .xml
2024            .find("<NVE>AA0001</NVE>")
2025            .expect("<NVE> must exist");
2026        let cest_pos = result.xml.find("<CEST>").expect("<CEST> must exist");
2027        assert!(nve_pos < cest_pos, "NVE must come before CEST");
2028    }
2029
2030    // ── gCred (crédito presumido ICMS) ──────────────────────────────────────
2031
2032    #[test]
2033    fn gcred_single_with_value_produces_correct_xml() {
2034        let gc = GCredData::new("SP000001", Rate4(50000)).v_cred_presumido(Cents(1500));
2035        let item = sample_item().g_cred(vec![gc]);
2036        let data = sample_build_data();
2037        let result = build_det(&item, &data).expect("build_det should succeed");
2038
2039        assert!(result.xml.contains(
2040            "<gCred><cCredPresumido>SP000001</cCredPresumido>\
2041             <pCredPresumido>5.0000</pCredPresumido>\
2042             <vCredPresumido>15.00</vCredPresumido></gCred>"
2043        ));
2044    }
2045
2046    #[test]
2047    fn gcred_without_value_omits_v_cred_presumido() {
2048        let gc = GCredData::new("RJ000002", Rate4(120000));
2049        let item = sample_item().g_cred(vec![gc]);
2050        let data = sample_build_data();
2051        let result = build_det(&item, &data).expect("build_det should succeed");
2052
2053        assert!(result.xml.contains(
2054            "<gCred>\
2055                <cCredPresumido>RJ000002</cCredPresumido>\
2056                <pCredPresumido>12.0000</pCredPresumido>\
2057            </gCred>"
2058        ));
2059        assert!(!result.xml.contains("<vCredPresumido>"));
2060    }
2061
2062    #[test]
2063    fn gcred_multiple_entries_up_to_four() {
2064        let entries = vec![
2065            GCredData::new("SP000001", Rate4(10000)).v_cred_presumido(Cents(100)),
2066            GCredData::new("SP000002", Rate4(20000)).v_cred_presumido(Cents(200)),
2067            GCredData::new("SP000003", Rate4(30000)).v_cred_presumido(Cents(300)),
2068            GCredData::new("SP000004", Rate4(40000)).v_cred_presumido(Cents(400)),
2069        ];
2070        let item = sample_item().g_cred(entries);
2071        let data = sample_build_data();
2072        let result = build_det(&item, &data).expect("build_det should succeed");
2073
2074        assert!(
2075            result
2076                .xml
2077                .contains("<cCredPresumido>SP000001</cCredPresumido>")
2078        );
2079        assert!(
2080            result
2081                .xml
2082                .contains("<cCredPresumido>SP000002</cCredPresumido>")
2083        );
2084        assert!(
2085            result
2086                .xml
2087                .contains("<cCredPresumido>SP000003</cCredPresumido>")
2088        );
2089        assert!(
2090            result
2091                .xml
2092                .contains("<cCredPresumido>SP000004</cCredPresumido>")
2093        );
2094    }
2095
2096    #[test]
2097    fn gcred_truncates_at_four_entries() {
2098        let entries = vec![
2099            GCredData::new("SP000001", Rate4(10000)),
2100            GCredData::new("SP000002", Rate4(20000)),
2101            GCredData::new("SP000003", Rate4(30000)),
2102            GCredData::new("SP000004", Rate4(40000)),
2103            GCredData::new("SP000005", Rate4(50000)),
2104        ];
2105        let item = sample_item().g_cred(entries);
2106        let data = sample_build_data();
2107        let result = build_det(&item, &data).expect("build_det should succeed");
2108
2109        assert!(
2110            result
2111                .xml
2112                .contains("<cCredPresumido>SP000004</cCredPresumido>")
2113        );
2114        assert!(
2115            !result
2116                .xml
2117                .contains("<cCredPresumido>SP000005</cCredPresumido>")
2118        );
2119    }
2120
2121    #[test]
2122    fn gcred_positioned_after_cbenef_before_cfop() {
2123        let gc = GCredData::new("MG000001", Rate4(50000)).v_cred_presumido(Cents(1000));
2124        let item = sample_item().c_benef("SEM CBENEF").g_cred(vec![gc]);
2125        let data = sample_build_data();
2126        let result = build_det(&item, &data).expect("build_det should succeed");
2127
2128        let cbenef_pos = result.xml.find("<cBenef>").expect("cBenef should exist");
2129        let gcred_pos = result.xml.find("<gCred>").expect("gCred should exist");
2130        let cfop_pos = result.xml.find("<CFOP>").expect("CFOP should exist");
2131
2132        assert!(gcred_pos > cbenef_pos, "gCred must come after cBenef");
2133        assert!(gcred_pos < cfop_pos, "gCred must come before CFOP");
2134    }
2135
2136    #[test]
2137    fn gcred_empty_vec_produces_no_gcred_tags() {
2138        let item = sample_item();
2139        let data = sample_build_data();
2140        let result = build_det(&item, &data).expect("build_det should succeed");
2141
2142        assert!(!result.xml.contains("<gCred>"));
2143    }
2144
2145    // ── Helper: Normal tax regime build data ─────────────────────────────
2146
2147    fn normal_build_data() -> InvoiceBuildData {
2148        let mut data = sample_build_data();
2149        data.issuer.tax_regime = TaxRegime::Normal;
2150        data
2151    }
2152
2153    fn pl010_build_data() -> InvoiceBuildData {
2154        let mut data = sample_build_data();
2155        data.schema_version = crate::types::SchemaVersion::PL010;
2156        data
2157    }
2158
2159    // ── CSOSN variants (Simples Nacional) ────────────────────────────────
2160
2161    #[test]
2162    fn csosn_101_produces_correct_xml() {
2163        let item = InvoiceItemData::new(
2164            1,
2165            "001",
2166            "Produto",
2167            "27101259",
2168            "5102",
2169            "UN",
2170            1.0,
2171            Cents(1000),
2172            Cents(1000),
2173            "101",
2174            Rate(1800),
2175            Cents(180),
2176            "99",
2177            "99",
2178        )
2179        .icms_p_cred_sn(Rate(500))
2180        .icms_v_cred_icms_sn(Cents(50));
2181        let data = sample_build_data();
2182        let result = build_det(&item, &data).expect("build_det should succeed");
2183
2184        assert!(result.xml.contains("<ICMSSN101>"));
2185        assert!(result.xml.contains("<CSOSN>101</CSOSN>"));
2186        assert!(result.xml.contains("<pCredSN>"));
2187        assert!(result.xml.contains("<vCredICMSSN>"));
2188    }
2189
2190    #[test]
2191    fn csosn_101_missing_p_cred_sn_returns_error() {
2192        let item = InvoiceItemData::new(
2193            1,
2194            "001",
2195            "Produto",
2196            "27101259",
2197            "5102",
2198            "UN",
2199            1.0,
2200            Cents(1000),
2201            Cents(1000),
2202            "101",
2203            Rate(0),
2204            Cents(0),
2205            "99",
2206            "99",
2207        )
2208        .icms_v_cred_icms_sn(Cents(50));
2209        let data = sample_build_data();
2210        let result = build_det(&item, &data);
2211        assert!(result.is_err());
2212    }
2213
2214    #[test]
2215    fn csosn_101_missing_v_cred_icms_sn_returns_error() {
2216        let item = InvoiceItemData::new(
2217            1,
2218            "001",
2219            "Produto",
2220            "27101259",
2221            "5102",
2222            "UN",
2223            1.0,
2224            Cents(1000),
2225            Cents(1000),
2226            "101",
2227            Rate(0),
2228            Cents(0),
2229            "99",
2230            "99",
2231        )
2232        .icms_p_cred_sn(Rate(500));
2233        let data = sample_build_data();
2234        let result = build_det(&item, &data);
2235        assert!(result.is_err());
2236    }
2237
2238    #[test]
2239    fn csosn_empty_defaults_to_102() {
2240        // When icms_cst is empty for Simples, it should default to "102"
2241        let item = InvoiceItemData::new(
2242            1,
2243            "001",
2244            "Produto",
2245            "27101259",
2246            "5102",
2247            "UN",
2248            1.0,
2249            Cents(1000),
2250            Cents(1000),
2251            "",
2252            Rate(0),
2253            Cents(0),
2254            "99",
2255            "99",
2256        );
2257        let data = sample_build_data();
2258        let result = build_det(&item, &data).expect("build_det should succeed");
2259
2260        assert!(result.xml.contains("<ICMSSN102>"));
2261        assert!(result.xml.contains("<CSOSN>102</CSOSN>"));
2262    }
2263
2264    #[test]
2265    fn csosn_103_produces_correct_xml() {
2266        let item = InvoiceItemData::new(
2267            1,
2268            "001",
2269            "Produto",
2270            "27101259",
2271            "5102",
2272            "UN",
2273            1.0,
2274            Cents(1000),
2275            Cents(1000),
2276            "103",
2277            Rate(0),
2278            Cents(0),
2279            "99",
2280            "99",
2281        );
2282        let data = sample_build_data();
2283        let result = build_det(&item, &data).expect("build_det should succeed");
2284        // 102, 103, 300, 400 all share the ICMSSN102 tag name
2285        assert!(result.xml.contains("<ICMSSN102>"));
2286        assert!(result.xml.contains("<CSOSN>103</CSOSN>"));
2287    }
2288
2289    #[test]
2290    fn csosn_300_produces_correct_xml() {
2291        let item = InvoiceItemData::new(
2292            1,
2293            "001",
2294            "Produto",
2295            "27101259",
2296            "5102",
2297            "UN",
2298            1.0,
2299            Cents(1000),
2300            Cents(1000),
2301            "300",
2302            Rate(0),
2303            Cents(0),
2304            "99",
2305            "99",
2306        );
2307        let data = sample_build_data();
2308        let result = build_det(&item, &data).expect("build_det should succeed");
2309        assert!(result.xml.contains("<ICMSSN102>"));
2310        assert!(result.xml.contains("<CSOSN>300</CSOSN>"));
2311    }
2312
2313    #[test]
2314    fn csosn_400_produces_correct_xml() {
2315        let item = InvoiceItemData::new(
2316            1,
2317            "001",
2318            "Produto",
2319            "27101259",
2320            "5102",
2321            "UN",
2322            1.0,
2323            Cents(1000),
2324            Cents(1000),
2325            "400",
2326            Rate(0),
2327            Cents(0),
2328            "99",
2329            "99",
2330        );
2331        let data = sample_build_data();
2332        let result = build_det(&item, &data).expect("build_det should succeed");
2333        assert!(result.xml.contains("<ICMSSN102>"));
2334        assert!(result.xml.contains("<CSOSN>400</CSOSN>"));
2335    }
2336
2337    #[test]
2338    fn csosn_201_produces_correct_xml() {
2339        let item = InvoiceItemData::new(
2340            1,
2341            "001",
2342            "Produto",
2343            "27101259",
2344            "5102",
2345            "UN",
2346            1.0,
2347            Cents(1000),
2348            Cents(1000),
2349            "201",
2350            Rate(1800),
2351            Cents(180),
2352            "99",
2353            "99",
2354        )
2355        .icms_mod_bc_st(4)
2356        .icms_v_bc_st(Cents(1200))
2357        .icms_p_icms_st(Rate(1200))
2358        .icms_v_icms_st(Cents(144))
2359        .icms_p_mva_st(Rate(5000))
2360        .icms_red_bc_st(Rate(1000))
2361        .icms_v_bc_fcp_st(Cents(1200))
2362        .icms_p_fcp_st(Rate(200))
2363        .icms_v_fcp_st(Cents(24))
2364        .icms_p_cred_sn(Rate(500))
2365        .icms_v_cred_icms_sn(Cents(50));
2366        let data = sample_build_data();
2367        let result = build_det(&item, &data).expect("build_det should succeed");
2368
2369        assert!(result.xml.contains("<ICMSSN201>"));
2370        assert!(result.xml.contains("<CSOSN>201</CSOSN>"));
2371        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2372        assert!(result.xml.contains("<vBCST>12.00</vBCST>"));
2373        assert!(result.xml.contains("<pICMSST>"));
2374        assert!(result.xml.contains("<vICMSST>"));
2375        assert!(result.xml.contains("<pMVAST>"));
2376        assert!(result.xml.contains("<pRedBCST>"));
2377    }
2378
2379    #[test]
2380    fn csosn_202_produces_correct_xml() {
2381        let item = InvoiceItemData::new(
2382            1,
2383            "001",
2384            "Produto",
2385            "27101259",
2386            "5102",
2387            "UN",
2388            1.0,
2389            Cents(1000),
2390            Cents(1000),
2391            "202",
2392            Rate(0),
2393            Cents(0),
2394            "99",
2395            "99",
2396        )
2397        .icms_mod_bc_st(4)
2398        .icms_v_bc_st(Cents(1200))
2399        .icms_p_icms_st(Rate(1200))
2400        .icms_v_icms_st(Cents(144))
2401        .icms_p_mva_st(Rate(5000))
2402        .icms_red_bc_st(Rate(1000))
2403        .icms_v_bc_fcp_st(Cents(1200))
2404        .icms_p_fcp_st(Rate(200))
2405        .icms_v_fcp_st(Cents(24));
2406        let data = sample_build_data();
2407        let result = build_det(&item, &data).expect("build_det should succeed");
2408
2409        assert!(result.xml.contains("<ICMSSN202>"));
2410        assert!(result.xml.contains("<CSOSN>202</CSOSN>"));
2411        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2412    }
2413
2414    #[test]
2415    fn csosn_203_produces_correct_xml() {
2416        let item = InvoiceItemData::new(
2417            1,
2418            "001",
2419            "Produto",
2420            "27101259",
2421            "5102",
2422            "UN",
2423            1.0,
2424            Cents(1000),
2425            Cents(1000),
2426            "203",
2427            Rate(0),
2428            Cents(0),
2429            "99",
2430            "99",
2431        )
2432        .icms_mod_bc_st(4)
2433        .icms_v_bc_st(Cents(1200))
2434        .icms_p_icms_st(Rate(1200))
2435        .icms_v_icms_st(Cents(144))
2436        .icms_p_mva_st(Rate(5000))
2437        .icms_red_bc_st(Rate(1000))
2438        .icms_v_bc_fcp_st(Cents(1200))
2439        .icms_p_fcp_st(Rate(200))
2440        .icms_v_fcp_st(Cents(24));
2441        let data = sample_build_data();
2442        let result = build_det(&item, &data).expect("build_det should succeed");
2443
2444        // 202 and 203 share the ICMSSN202 tag name
2445        assert!(result.xml.contains("<ICMSSN202>"));
2446        assert!(result.xml.contains("<CSOSN>203</CSOSN>"));
2447        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2448    }
2449
2450    #[test]
2451    fn csosn_500_produces_correct_xml() {
2452        let item = InvoiceItemData::new(
2453            1,
2454            "001",
2455            "Produto",
2456            "27101259",
2457            "5102",
2458            "UN",
2459            1.0,
2460            Cents(1000),
2461            Cents(1000),
2462            "500",
2463            Rate(0),
2464            Cents(0),
2465            "99",
2466            "99",
2467        )
2468        .icms_v_icms_substituto(Cents(200));
2469        let data = sample_build_data();
2470        let result = build_det(&item, &data).expect("build_det should succeed");
2471
2472        assert!(result.xml.contains("<ICMSSN500>"));
2473        assert!(result.xml.contains("<CSOSN>500</CSOSN>"));
2474        assert!(result.xml.contains("<vICMSSubstituto>"));
2475    }
2476
2477    #[test]
2478    fn csosn_900_produces_correct_xml() {
2479        let item = InvoiceItemData::new(
2480            1,
2481            "001",
2482            "Produto",
2483            "27101259",
2484            "5102",
2485            "UN",
2486            1.0,
2487            Cents(1000),
2488            Cents(1000),
2489            "900",
2490            Rate(1800),
2491            Cents(180),
2492            "99",
2493            "99",
2494        )
2495        .icms_mod_bc(3)
2496        .icms_red_bc(Rate(1000))
2497        .icms_mod_bc_st(4)
2498        .icms_p_mva_st(Rate(5000))
2499        .icms_red_bc_st(Rate(1000))
2500        .icms_v_bc_st(Cents(1200))
2501        .icms_p_icms_st(Rate(1200))
2502        .icms_v_icms_st(Cents(144))
2503        .icms_v_bc_fcp_st(Cents(1200))
2504        .icms_p_fcp_st(Rate(200))
2505        .icms_v_fcp_st(Cents(24))
2506        .icms_p_cred_sn(Rate(500))
2507        .icms_v_cred_icms_sn(Cents(50));
2508        let data = sample_build_data();
2509        let result = build_det(&item, &data).expect("build_det should succeed");
2510
2511        assert!(result.xml.contains("<ICMSSN900>"));
2512        assert!(result.xml.contains("<CSOSN>900</CSOSN>"));
2513        assert!(result.xml.contains("<modBC>3</modBC>"));
2514    }
2515
2516    #[test]
2517    fn csosn_unsupported_returns_error() {
2518        let item = InvoiceItemData::new(
2519            1,
2520            "001",
2521            "Produto",
2522            "27101259",
2523            "5102",
2524            "UN",
2525            1.0,
2526            Cents(1000),
2527            Cents(1000),
2528            "999",
2529            Rate(0),
2530            Cents(0),
2531            "99",
2532            "99",
2533        );
2534        let data = sample_build_data();
2535        let result = build_det(&item, &data);
2536        assert!(result.is_err());
2537        let err = result.unwrap_err();
2538        assert!(
2539            matches!(err, FiscalError::UnsupportedIcmsCsosn(ref c) if c == "999"),
2540            "expected UnsupportedIcmsCsosn, got {:?}",
2541            err
2542        );
2543    }
2544
2545    // ── ICMS CST variants (Normal tax regime) ────────────────────────────
2546
2547    #[test]
2548    fn cst_10_produces_correct_xml() {
2549        let item = InvoiceItemData::new(
2550            1,
2551            "001",
2552            "Produto",
2553            "27101259",
2554            "5102",
2555            "UN",
2556            1.0,
2557            Cents(1000),
2558            Cents(1000),
2559            "10",
2560            Rate(1800),
2561            Cents(180),
2562            "99",
2563            "99",
2564        )
2565        .icms_mod_bc_st(4)
2566        .icms_v_bc_st(Cents(1200))
2567        .icms_p_icms_st(Rate(1200))
2568        .icms_v_icms_st(Cents(144))
2569        .icms_v_bc_fcp(Cents(1000))
2570        .icms_p_fcp(Rate(200))
2571        .icms_v_fcp(Cents(20))
2572        .icms_p_mva_st(Rate(5000))
2573        .icms_red_bc_st(Rate(1000))
2574        .icms_v_bc_fcp_st(Cents(1200))
2575        .icms_p_fcp_st(Rate(200))
2576        .icms_v_fcp_st(Cents(24));
2577        let data = normal_build_data();
2578        let result = build_det(&item, &data).expect("build_det should succeed");
2579
2580        assert!(result.xml.contains("<ICMS10>"));
2581        assert!(result.xml.contains("<CST>10</CST>"));
2582        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2583        assert!(result.xml.contains("<vBCST>12.00</vBCST>"));
2584        assert!(result.xml.contains("<pICMSST>"));
2585        assert!(result.xml.contains("<vICMSST>"));
2586    }
2587
2588    #[test]
2589    fn cst_20_produces_correct_xml() {
2590        let item = InvoiceItemData::new(
2591            1,
2592            "001",
2593            "Produto",
2594            "27101259",
2595            "5102",
2596            "UN",
2597            1.0,
2598            Cents(1000),
2599            Cents(1000),
2600            "20",
2601            Rate(1800),
2602            Cents(180),
2603            "99",
2604            "99",
2605        )
2606        .icms_mod_bc(3)
2607        .icms_red_bc(Rate(2000))
2608        .icms_v_bc_fcp(Cents(1000))
2609        .icms_p_fcp(Rate(200))
2610        .icms_v_fcp(Cents(20))
2611        .icms_v_icms_deson(Cents(50))
2612        .icms_mot_des_icms(9)
2613        .icms_ind_deduz_deson("1");
2614        let data = normal_build_data();
2615        let result = build_det(&item, &data).expect("build_det should succeed");
2616
2617        assert!(result.xml.contains("<ICMS20>"));
2618        assert!(result.xml.contains("<CST>20</CST>"));
2619        assert!(result.xml.contains("<pRedBC>"));
2620        assert!(result.xml.contains("<vICMSDeson>"));
2621        assert!(result.xml.contains("<motDesICMS>9</motDesICMS>"));
2622        assert!(result.xml.contains("<indDeduzDeson>1</indDeduzDeson>"));
2623        assert!(result.ind_deduz_deson);
2624    }
2625
2626    #[test]
2627    fn cst_30_produces_correct_xml() {
2628        let item = InvoiceItemData::new(
2629            1,
2630            "001",
2631            "Produto",
2632            "27101259",
2633            "5102",
2634            "UN",
2635            1.0,
2636            Cents(1000),
2637            Cents(1000),
2638            "30",
2639            Rate(0),
2640            Cents(0),
2641            "99",
2642            "99",
2643        )
2644        .icms_mod_bc_st(4)
2645        .icms_v_bc_st(Cents(1200))
2646        .icms_p_icms_st(Rate(1200))
2647        .icms_v_icms_st(Cents(144))
2648        .icms_p_mva_st(Rate(5000))
2649        .icms_red_bc_st(Rate(1000))
2650        .icms_v_bc_fcp_st(Cents(1200))
2651        .icms_p_fcp_st(Rate(200))
2652        .icms_v_fcp_st(Cents(24))
2653        .icms_v_icms_deson(Cents(50))
2654        .icms_mot_des_icms(9)
2655        .icms_ind_deduz_deson("1");
2656        let data = normal_build_data();
2657        let result = build_det(&item, &data).expect("build_det should succeed");
2658
2659        assert!(result.xml.contains("<ICMS30>"));
2660        assert!(result.xml.contains("<CST>30</CST>"));
2661        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2662        assert!(result.xml.contains("<vICMSDeson>"));
2663        assert!(result.xml.contains("<motDesICMS>9</motDesICMS>"));
2664    }
2665
2666    #[test]
2667    fn cst_40_produces_correct_xml() {
2668        let item = InvoiceItemData::new(
2669            1,
2670            "001",
2671            "Produto",
2672            "27101259",
2673            "5102",
2674            "UN",
2675            1.0,
2676            Cents(1000),
2677            Cents(1000),
2678            "40",
2679            Rate(0),
2680            Cents(0),
2681            "99",
2682            "99",
2683        )
2684        .icms_v_icms_deson(Cents(100))
2685        .icms_mot_des_icms(1)
2686        .icms_ind_deduz_deson("1");
2687        let data = normal_build_data();
2688        let result = build_det(&item, &data).expect("build_det should succeed");
2689
2690        assert!(result.xml.contains("<ICMS40>"));
2691        assert!(result.xml.contains("<CST>40</CST>"));
2692        assert!(result.xml.contains("<vICMSDeson>"));
2693        assert!(result.xml.contains("<motDesICMS>1</motDesICMS>"));
2694    }
2695
2696    #[test]
2697    fn cst_41_produces_correct_xml() {
2698        let item = InvoiceItemData::new(
2699            1,
2700            "001",
2701            "Produto",
2702            "27101259",
2703            "5102",
2704            "UN",
2705            1.0,
2706            Cents(1000),
2707            Cents(1000),
2708            "41",
2709            Rate(0),
2710            Cents(0),
2711            "99",
2712            "99",
2713        )
2714        .icms_v_icms_deson(Cents(100))
2715        .icms_mot_des_icms(1)
2716        .icms_ind_deduz_deson("1");
2717        let data = normal_build_data();
2718        let result = build_det(&item, &data).expect("build_det should succeed");
2719
2720        // 40, 41, 50 all share the ICMS40 tag name
2721        assert!(result.xml.contains("<ICMS40>"));
2722        assert!(result.xml.contains("<CST>41</CST>"));
2723        assert!(result.xml.contains("<vICMSDeson>"));
2724    }
2725
2726    #[test]
2727    fn cst_50_produces_correct_xml() {
2728        let item = InvoiceItemData::new(
2729            1,
2730            "001",
2731            "Produto",
2732            "27101259",
2733            "5102",
2734            "UN",
2735            1.0,
2736            Cents(1000),
2737            Cents(1000),
2738            "50",
2739            Rate(0),
2740            Cents(0),
2741            "99",
2742            "99",
2743        )
2744        .icms_v_icms_deson(Cents(100))
2745        .icms_mot_des_icms(1)
2746        .icms_ind_deduz_deson("1");
2747        let data = normal_build_data();
2748        let result = build_det(&item, &data).expect("build_det should succeed");
2749
2750        // 40, 41, 50 all share the ICMS40 tag name
2751        assert!(result.xml.contains("<ICMS40>"));
2752        assert!(result.xml.contains("<CST>50</CST>"));
2753        assert!(result.xml.contains("<vICMSDeson>"));
2754    }
2755
2756    #[test]
2757    fn cst_51_produces_correct_xml() {
2758        let item = InvoiceItemData::new(
2759            1,
2760            "001",
2761            "Produto",
2762            "27101259",
2763            "5102",
2764            "UN",
2765            1.0,
2766            Cents(1000),
2767            Cents(1000),
2768            "51",
2769            Rate(1800),
2770            Cents(180),
2771            "99",
2772            "99",
2773        )
2774        .icms_mod_bc(3)
2775        .icms_red_bc(Rate(1000))
2776        .icms_v_bc_fcp(Cents(1000))
2777        .icms_p_fcp(Rate(200))
2778        .icms_v_fcp(Cents(20));
2779        let data = normal_build_data();
2780        let result = build_det(&item, &data).expect("build_det should succeed");
2781
2782        assert!(result.xml.contains("<ICMS51>"));
2783        assert!(result.xml.contains("<CST>51</CST>"));
2784        assert!(result.xml.contains("<modBC>3</modBC>"));
2785        assert!(result.xml.contains("<pRedBC>"));
2786        assert!(result.xml.contains("<vICMS>"));
2787    }
2788
2789    #[test]
2790    fn cst_60_produces_correct_xml() {
2791        let item = InvoiceItemData::new(
2792            1,
2793            "001",
2794            "Produto",
2795            "27101259",
2796            "5102",
2797            "UN",
2798            1.0,
2799            Cents(1000),
2800            Cents(1000),
2801            "60",
2802            Rate(0),
2803            Cents(0),
2804            "99",
2805            "99",
2806        )
2807        .icms_v_icms_substituto(Cents(200));
2808        let data = normal_build_data();
2809        let result = build_det(&item, &data).expect("build_det should succeed");
2810
2811        assert!(result.xml.contains("<ICMS60>"));
2812        assert!(result.xml.contains("<CST>60</CST>"));
2813        assert!(result.xml.contains("<vICMSSubstituto>"));
2814    }
2815
2816    #[test]
2817    fn cst_70_produces_correct_xml() {
2818        let item = InvoiceItemData::new(
2819            1,
2820            "001",
2821            "Produto",
2822            "27101259",
2823            "5102",
2824            "UN",
2825            1.0,
2826            Cents(1000),
2827            Cents(1000),
2828            "70",
2829            Rate(1800),
2830            Cents(180),
2831            "99",
2832            "99",
2833        )
2834        .icms_mod_bc(3)
2835        .icms_red_bc(Rate(2000))
2836        .icms_mod_bc_st(4)
2837        .icms_v_bc_st(Cents(1200))
2838        .icms_p_icms_st(Rate(1200))
2839        .icms_v_icms_st(Cents(144))
2840        .icms_v_bc_fcp(Cents(1000))
2841        .icms_p_fcp(Rate(200))
2842        .icms_v_fcp(Cents(20))
2843        .icms_p_mva_st(Rate(5000))
2844        .icms_red_bc_st(Rate(1000))
2845        .icms_v_bc_fcp_st(Cents(1200))
2846        .icms_p_fcp_st(Rate(200))
2847        .icms_v_fcp_st(Cents(24))
2848        .icms_v_icms_deson(Cents(50))
2849        .icms_mot_des_icms(9)
2850        .icms_ind_deduz_deson("1");
2851        let data = normal_build_data();
2852        let result = build_det(&item, &data).expect("build_det should succeed");
2853
2854        assert!(result.xml.contains("<ICMS70>"));
2855        assert!(result.xml.contains("<CST>70</CST>"));
2856        assert!(result.xml.contains("<pRedBC>"));
2857        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2858        assert!(result.xml.contains("<vICMSDeson>"));
2859    }
2860
2861    #[test]
2862    fn cst_90_produces_correct_xml() {
2863        let item = InvoiceItemData::new(
2864            1,
2865            "001",
2866            "Produto",
2867            "27101259",
2868            "5102",
2869            "UN",
2870            1.0,
2871            Cents(1000),
2872            Cents(1000),
2873            "90",
2874            Rate(1800),
2875            Cents(180),
2876            "99",
2877            "99",
2878        )
2879        .icms_mod_bc(3)
2880        .icms_red_bc(Rate(1000))
2881        .icms_v_bc_fcp(Cents(1000))
2882        .icms_p_fcp(Rate(200))
2883        .icms_v_fcp(Cents(20))
2884        .icms_mod_bc_st(4)
2885        .icms_p_mva_st(Rate(5000))
2886        .icms_red_bc_st(Rate(1000))
2887        .icms_v_bc_st(Cents(1200))
2888        .icms_p_icms_st(Rate(1200))
2889        .icms_v_icms_st(Cents(144))
2890        .icms_v_bc_fcp_st(Cents(1200))
2891        .icms_p_fcp_st(Rate(200))
2892        .icms_v_fcp_st(Cents(24))
2893        .icms_v_icms_deson(Cents(50))
2894        .icms_mot_des_icms(9)
2895        .icms_ind_deduz_deson("1");
2896        let data = normal_build_data();
2897        let result = build_det(&item, &data).expect("build_det should succeed");
2898
2899        assert!(result.xml.contains("<ICMS90>"));
2900        assert!(result.xml.contains("<CST>90</CST>"));
2901        assert!(result.xml.contains("<modBC>3</modBC>"));
2902        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2903        assert!(result.xml.contains("<vICMSDeson>"));
2904    }
2905
2906    #[test]
2907    fn cst_unsupported_returns_error() {
2908        let item = InvoiceItemData::new(
2909            1,
2910            "001",
2911            "Produto",
2912            "27101259",
2913            "5102",
2914            "UN",
2915            1.0,
2916            Cents(1000),
2917            Cents(1000),
2918            "99",
2919            Rate(0),
2920            Cents(0),
2921            "99",
2922            "99",
2923        );
2924        let data = normal_build_data();
2925        let result = build_det(&item, &data);
2926        assert!(result.is_err());
2927        let err = result.unwrap_err();
2928        assert!(
2929            matches!(err, FiscalError::UnsupportedIcmsCst(ref c) if c == "99"),
2930            "expected UnsupportedIcmsCst, got {:?}",
2931            err
2932        );
2933    }
2934
2935    // ── IPI (optional) ───────────────────────────────────────────────────
2936
2937    #[test]
2938    fn ipi_produces_correct_xml() {
2939        let item = sample_item()
2940            .ipi_cst("50")
2941            .ipi_c_enq("999")
2942            .ipi_v_bc(Cents(10000))
2943            .ipi_p_ipi(Rate(500))
2944            .ipi_v_ipi(Cents(500));
2945        let data = sample_build_data();
2946        let result = build_det(&item, &data).expect("build_det should succeed");
2947
2948        assert!(result.xml.contains("<IPI>"));
2949        assert!(result.xml.contains("<CST>50</CST>"));
2950        assert!(result.xml.contains("<cEnq>999</cEnq>"));
2951        assert!(result.xml.contains("<vIPI>5.00</vIPI>"));
2952        assert_eq!(result.v_ipi, 500);
2953    }
2954
2955    #[test]
2956    fn ipi_default_c_enq_when_missing() {
2957        let item = sample_item()
2958            .ipi_cst("50")
2959            .ipi_v_bc(Cents(10000))
2960            .ipi_p_ipi(Rate(500))
2961            .ipi_v_ipi(Cents(500));
2962        let data = sample_build_data();
2963        let result = build_det(&item, &data).expect("build_det should succeed");
2964
2965        assert!(result.xml.contains("<cEnq>999</cEnq>"));
2966    }
2967
2968    #[test]
2969    fn ipi_with_quantity_based() {
2970        let item = sample_item()
2971            .ipi_cst("50")
2972            .ipi_q_unid(100)
2973            .ipi_v_unid(50)
2974            .ipi_v_ipi(Cents(5000));
2975        let data = sample_build_data();
2976        let result = build_det(&item, &data).expect("build_det should succeed");
2977
2978        assert!(result.xml.contains("<IPI>"));
2979        assert_eq!(result.v_ipi, 5000);
2980    }
2981
2982    #[test]
2983    fn no_ipi_when_cst_absent() {
2984        let item = sample_item();
2985        let data = sample_build_data();
2986        let result = build_det(&item, &data).expect("build_det should succeed");
2987
2988        assert!(!result.xml.contains("<IPI>"));
2989        assert_eq!(result.v_ipi, 0);
2990    }
2991
2992    // ── II (Import Tax) ──────────────────────────────────────────────────
2993
2994    #[test]
2995    fn ii_produces_correct_xml() {
2996        let item = sample_item()
2997            .ii_v_bc(Cents(50000))
2998            .ii_v_desp_adu(Cents(5000))
2999            .ii_v_ii(Cents(10000))
3000            .ii_v_iof(Cents(2000));
3001        let data = sample_build_data();
3002        let result = build_det(&item, &data).expect("build_det should succeed");
3003
3004        assert!(result.xml.contains("<II>"));
3005        assert!(result.xml.contains("<vBC>500.00</vBC>"));
3006        assert!(result.xml.contains("<vDespAdu>50.00</vDespAdu>"));
3007        assert!(result.xml.contains("<vII>100.00</vII>"));
3008        assert!(result.xml.contains("<vIOF>20.00</vIOF>"));
3009        assert_eq!(result.v_ii, 10000);
3010    }
3011
3012    #[test]
3013    fn no_ii_when_absent() {
3014        let item = sample_item();
3015        let data = sample_build_data();
3016        let result = build_det(&item, &data).expect("build_det should succeed");
3017
3018        assert!(!result.xml.contains("<II>"));
3019        assert_eq!(result.v_ii, 0);
3020    }
3021
3022    // ── PIS-ST / COFINS-ST ──────────────────────────────────────────────
3023
3024    #[test]
3025    fn pis_st_replaces_pis_and_accumulates_when_ind_soma_1() {
3026        use crate::tax_pis_cofins_ipi::PisStData;
3027        let pis_st = PisStData::new(Cents(500))
3028            .v_bc(Cents(10000))
3029            .p_pis(Rate4(16500))
3030            .ind_soma_pis_st(1);
3031        let item = sample_item().pis_st(pis_st);
3032        let data = sample_build_data();
3033        let result = build_det(&item, &data).expect("build_det should succeed");
3034
3035        assert!(result.xml.contains("<PISST>"));
3036        assert!(!result.xml.contains("<PISAliq>"));
3037        assert_eq!(result.v_pis_st, 500);
3038    }
3039
3040    #[test]
3041    fn pis_st_does_not_accumulate_when_ind_soma_0() {
3042        use crate::tax_pis_cofins_ipi::PisStData;
3043        let pis_st = PisStData::new(Cents(500))
3044            .v_bc(Cents(10000))
3045            .p_pis(Rate4(16500))
3046            .ind_soma_pis_st(0);
3047        let item = sample_item().pis_st(pis_st);
3048        let data = sample_build_data();
3049        let result = build_det(&item, &data).expect("build_det should succeed");
3050
3051        assert!(result.xml.contains("<PISST>"));
3052        assert_eq!(result.v_pis_st, 0);
3053    }
3054
3055    #[test]
3056    fn cofins_st_replaces_cofins_and_accumulates_when_ind_soma_1() {
3057        use crate::tax_pis_cofins_ipi::CofinsStData;
3058        let cofins_st = CofinsStData::new(Cents(750))
3059            .v_bc(Cents(10000))
3060            .p_cofins(Rate4(76000))
3061            .ind_soma_cofins_st(1);
3062        let item = sample_item().cofins_st(cofins_st);
3063        let data = sample_build_data();
3064        let result = build_det(&item, &data).expect("build_det should succeed");
3065
3066        assert!(result.xml.contains("<COFINSST>"));
3067        assert!(!result.xml.contains("<COFINSAliq>"));
3068        assert_eq!(result.v_cofins_st, 750);
3069    }
3070
3071    #[test]
3072    fn cofins_st_does_not_accumulate_when_ind_soma_0() {
3073        use crate::tax_pis_cofins_ipi::CofinsStData;
3074        let cofins_st = CofinsStData::new(Cents(750))
3075            .v_bc(Cents(10000))
3076            .p_cofins(Rate4(76000))
3077            .ind_soma_cofins_st(0);
3078        let item = sample_item().cofins_st(cofins_st);
3079        let data = sample_build_data();
3080        let result = build_det(&item, &data).expect("build_det should succeed");
3081
3082        assert!(result.xml.contains("<COFINSST>"));
3083        assert_eq!(result.v_cofins_st, 0);
3084    }
3085
3086    // ── IS / IBS-CBS (PL010 schema) ─────────────────────────────────────
3087
3088    #[test]
3089    fn is_data_emitted_with_pl010_schema() {
3090        use crate::tax_is::IsData;
3091        let is = IsData::new("00", "001", "5.00");
3092        let item = sample_item().is_data(is);
3093        let data = pl010_build_data();
3094        let result = build_det(&item, &data).expect("build_det should succeed");
3095
3096        assert!(result.xml.contains("<IS>"));
3097    }
3098
3099    #[test]
3100    fn is_data_not_emitted_with_pl009_schema() {
3101        use crate::tax_is::IsData;
3102        let is = IsData::new("00", "001", "5.00");
3103        let item = sample_item().is_data(is);
3104        let data = sample_build_data(); // PL009
3105        let result = build_det(&item, &data).expect("build_det should succeed");
3106
3107        assert!(!result.xml.contains("<IS>"));
3108    }
3109
3110    #[test]
3111    fn ibs_cbs_data_emitted_with_pl010_schema() {
3112        use crate::tax_ibs_cbs::IbsCbsData;
3113        let ibs_cbs = IbsCbsData::new("00", "001");
3114        let item = sample_item().ibs_cbs(ibs_cbs);
3115        let data = pl010_build_data();
3116        let result = build_det(&item, &data).expect("build_det should succeed");
3117
3118        assert!(result.xml.contains("<IBSCBS>"));
3119    }
3120
3121    // ── CEST with indEscala and CNPJFab ─────────────────────────────────
3122
3123    #[test]
3124    fn cest_with_ind_escala_and_cnpj_fab() {
3125        let item = sample_item()
3126            .cest("1234567")
3127            .cest_ind_escala("S")
3128            .cest_cnpj_fab("12345678000199");
3129        let data = sample_build_data();
3130        let result = build_det(&item, &data).expect("build_det should succeed");
3131
3132        assert!(result.xml.contains("<CEST>1234567</CEST>"));
3133        assert!(result.xml.contains("<indEscala>S</indEscala>"));
3134        assert!(result.xml.contains("<CNPJFab>12345678000199</CNPJFab>"));
3135    }
3136
3137    // ── EXTIPI ──────────────────────────────────────────────────────────
3138
3139    #[test]
3140    fn extipi_produces_correct_xml() {
3141        let item = sample_item().extipi("01");
3142        let data = sample_build_data();
3143        let result = build_det(&item, &data).expect("build_det should succeed");
3144
3145        assert!(result.xml.contains("<EXTIPI>01</EXTIPI>"));
3146        // EXTIPI must appear after NCM and before CFOP
3147        let ncm_pos = result.xml.find("<NCM>").unwrap();
3148        let extipi_pos = result.xml.find("<EXTIPI>").unwrap();
3149        let cfop_pos = result.xml.find("<CFOP>").unwrap();
3150        assert!(extipi_pos > ncm_pos);
3151        assert!(extipi_pos < cfop_pos);
3152    }
3153
3154    // ── nItemPed, nFCI ──────────────────────────────────────────────────
3155
3156    #[test]
3157    fn n_item_ped_produces_correct_xml() {
3158        let item = sample_item().n_item_ped("5");
3159        let data = sample_build_data();
3160        let result = build_det(&item, &data).expect("build_det should succeed");
3161
3162        assert!(result.xml.contains("<nItemPed>5</nItemPed>"));
3163    }
3164
3165    #[test]
3166    fn n_fci_produces_correct_xml() {
3167        let item = sample_item().n_fci("B01F70AF-10BF-4B1F-848C-65FF57F616FE");
3168        let data = sample_build_data();
3169        let result = build_det(&item, &data).expect("build_det should succeed");
3170
3171        assert!(
3172            result
3173                .xml
3174                .contains("<nFCI>B01F70AF-10BF-4B1F-848C-65FF57F616FE</nFCI>")
3175        );
3176    }
3177
3178    // ── Veículo (veicProd) ──────────────────────────────────────────────
3179
3180    #[test]
3181    fn veic_prod_produces_correct_xml() {
3182        let veic = VeicProdData::new(
3183            "1",
3184            "9BWZZZ377VT004251",
3185            "1",
3186            "PRATA",
3187            "100",
3188            "1600",
3189            "1050",
3190            "1250",
3191            "ABC123",
3192            "1",
3193            "MOT123",
3194            "1500",
3195            "2600",
3196            "2025",
3197            "2025",
3198            "M",
3199            "06",
3200            "1",
3201            "R",
3202            "1",
3203            "MOD001",
3204            "02",
3205            "5",
3206            "0",
3207        );
3208        let item = sample_item().veic_prod(veic);
3209        let data = sample_build_data();
3210        let result = build_det(&item, &data).expect("build_det should succeed");
3211
3212        assert!(result.xml.contains("<veicProd>"));
3213        assert!(result.xml.contains("<tpOp>1</tpOp>"));
3214        assert!(result.xml.contains("<chassi>9BWZZZ377VT004251</chassi>"));
3215        assert!(result.xml.contains("<cCor>1</cCor>"));
3216        assert!(result.xml.contains("<xCor>PRATA</xCor>"));
3217        assert!(result.xml.contains("<pot>100</pot>"));
3218        assert!(result.xml.contains("<cilin>1600</cilin>"));
3219        assert!(result.xml.contains("<pesoL>1050</pesoL>"));
3220        assert!(result.xml.contains("<pesoB>1250</pesoB>"));
3221        assert!(result.xml.contains("<nSerie>ABC123</nSerie>"));
3222        assert!(result.xml.contains("<tpComb>1</tpComb>"));
3223        assert!(result.xml.contains("<nMotor>MOT123</nMotor>"));
3224        assert!(result.xml.contains("<CMT>1500</CMT>"));
3225        assert!(result.xml.contains("<dist>2600</dist>"));
3226        assert!(result.xml.contains("<anoMod>2025</anoMod>"));
3227        assert!(result.xml.contains("<anoFab>2025</anoFab>"));
3228        assert!(result.xml.contains("<tpPint>M</tpPint>"));
3229        assert!(result.xml.contains("<tpVeic>06</tpVeic>"));
3230        assert!(result.xml.contains("<espVeic>1</espVeic>"));
3231        assert!(result.xml.contains("<VIN>R</VIN>"));
3232        assert!(result.xml.contains("<condVeic>1</condVeic>"));
3233        assert!(result.xml.contains("<cMod>MOD001</cMod>"));
3234        assert!(result.xml.contains("<cCorDENATRAN>02</cCorDENATRAN>"));
3235        assert!(result.xml.contains("<lota>5</lota>"));
3236        assert!(result.xml.contains("<tpRest>0</tpRest>"));
3237        assert!(result.xml.contains("</veicProd>"));
3238    }
3239
3240    // ── Medicamento (med) ───────────────────────────────────────────────
3241
3242    #[test]
3243    fn med_with_anvisa_code() {
3244        let med = MedData::new(Cents(5000)).c_prod_anvisa("1234567890123");
3245        let item = sample_item().med(med);
3246        let data = sample_build_data();
3247        let result = build_det(&item, &data).expect("build_det should succeed");
3248
3249        assert!(result.xml.contains("<med>"));
3250        assert!(
3251            result
3252                .xml
3253                .contains("<cProdANVISA>1234567890123</cProdANVISA>")
3254        );
3255        assert!(result.xml.contains("<vPMC>50.00</vPMC>"));
3256        assert!(result.xml.contains("</med>"));
3257    }
3258
3259    #[test]
3260    fn med_with_exemption_reason() {
3261        let med = MedData::new(Cents(3000)).x_motivo_isencao("Medicamento isento de registro");
3262        let item = sample_item().med(med);
3263        let data = sample_build_data();
3264        let result = build_det(&item, &data).expect("build_det should succeed");
3265
3266        assert!(result.xml.contains("<med>"));
3267        assert!(
3268            result
3269                .xml
3270                .contains("<xMotivoIsencao>Medicamento isento de registro</xMotivoIsencao>")
3271        );
3272        assert!(result.xml.contains("<vPMC>30.00</vPMC>"));
3273        assert!(!result.xml.contains("<cProdANVISA>"));
3274    }
3275
3276    // ── Arma (weapon) ───────────────────────────────────────────────────
3277
3278    #[test]
3279    fn arma_single_produces_correct_xml() {
3280        let arma = ArmaData::new("0", "SN12345", "CN6789", "Pistola Taurus");
3281        let item = sample_item().arma(vec![arma]);
3282        let data = sample_build_data();
3283        let result = build_det(&item, &data).expect("build_det should succeed");
3284
3285        assert!(result.xml.contains("<arma>"));
3286        assert!(result.xml.contains("<tpArma>0</tpArma>"));
3287        assert!(result.xml.contains("<nSerie>SN12345</nSerie>"));
3288        assert!(result.xml.contains("<nCano>CN6789</nCano>"));
3289        assert!(result.xml.contains("<descr>Pistola Taurus</descr>"));
3290        assert!(result.xml.contains("</arma>"));
3291    }
3292
3293    #[test]
3294    fn arma_multiple_produces_multiple_elements() {
3295        let a1 = ArmaData::new("0", "SN001", "CN001", "Arma 1");
3296        let a2 = ArmaData::new("1", "SN002", "CN002", "Arma 2");
3297        let item = sample_item().arma(vec![a1, a2]);
3298        let data = sample_build_data();
3299        let result = build_det(&item, &data).expect("build_det should succeed");
3300
3301        assert_eq!(result.xml.matches("<arma>").count(), 2);
3302        assert!(result.xml.contains("<nSerie>SN001</nSerie>"));
3303        assert!(result.xml.contains("<nSerie>SN002</nSerie>"));
3304    }
3305
3306    // ── nRECOPI ─────────────────────────────────────────────────────────
3307
3308    #[test]
3309    fn n_recopi_produces_correct_xml() {
3310        let item = sample_item().n_recopi("20250000001234567890");
3311        let data = sample_build_data();
3312        let result = build_det(&item, &data).expect("build_det should succeed");
3313
3314        assert!(
3315            result
3316                .xml
3317                .contains("<nRECOPI>20250000001234567890</nRECOPI>")
3318        );
3319    }
3320
3321    #[test]
3322    fn n_recopi_empty_not_emitted() {
3323        let item = sample_item().n_recopi("");
3324        let data = sample_build_data();
3325        let result = build_det(&item, &data).expect("build_det should succeed");
3326
3327        assert!(!result.xml.contains("<nRECOPI>"));
3328    }
3329
3330    // ── Rastro (batch tracking) ─────────────────────────────────────────
3331
3332    #[test]
3333    fn rastro_single_produces_correct_xml() {
3334        let r = RastroData::new("LOTE001", 10.5, "2025-01-01", "2026-01-01");
3335        let item = sample_item().rastro(vec![r]);
3336        let data = sample_build_data();
3337        let result = build_det(&item, &data).expect("build_det should succeed");
3338
3339        assert!(result.xml.contains("<rastro>"));
3340        assert!(result.xml.contains("<nLote>LOTE001</nLote>"));
3341        assert!(result.xml.contains("<qLote>10.500</qLote>"));
3342        assert!(result.xml.contains("<dFab>2025-01-01</dFab>"));
3343        assert!(result.xml.contains("<dVal>2026-01-01</dVal>"));
3344        assert!(result.xml.contains("</rastro>"));
3345    }
3346
3347    #[test]
3348    fn rastro_with_c_agreg() {
3349        let r = RastroData::new("LOTE002", 5.0, "2025-06-01", "2026-06-01").c_agreg("AGREG001");
3350        let item = sample_item().rastro(vec![r]);
3351        let data = sample_build_data();
3352        let result = build_det(&item, &data).expect("build_det should succeed");
3353
3354        assert!(result.xml.contains("<cAgreg>AGREG001</cAgreg>"));
3355    }
3356
3357    // ── obsItem with obsFisco ───────────────────────────────────────────
3358
3359    #[test]
3360    fn obs_item_with_obs_cont_only() {
3361        use crate::types::{ObsField, ObsItemData};
3362        let obs = ObsItemData::new().obs_cont(ObsField::new("campo1", "texto1"));
3363        let item = sample_item().obs_item(obs);
3364        let data = sample_build_data();
3365        let result = build_det(&item, &data).expect("build_det should succeed");
3366
3367        assert!(result.xml.contains("<obsItem>"));
3368        assert!(result.xml.contains("<obsCont xCampo=\"campo1\">"));
3369        assert!(result.xml.contains("<xTexto>texto1</xTexto>"));
3370        assert!(!result.xml.contains("<obsFisco"));
3371    }
3372
3373    #[test]
3374    fn obs_item_with_obs_fisco() {
3375        use crate::types::{ObsField, ObsItemData};
3376        let obs = ObsItemData::new().obs_fisco(ObsField::new("campo_fisco", "texto_fisco"));
3377        let item = sample_item().obs_item(obs);
3378        let data = sample_build_data();
3379        let result = build_det(&item, &data).expect("build_det should succeed");
3380
3381        assert!(result.xml.contains("<obsItem>"));
3382        assert!(result.xml.contains("<obsFisco xCampo=\"campo_fisco\">"));
3383        assert!(result.xml.contains("<xTexto>texto_fisco</xTexto>"));
3384    }
3385
3386    #[test]
3387    fn obs_item_with_both_obs_cont_and_obs_fisco() {
3388        use crate::types::{ObsField, ObsItemData};
3389        let obs = ObsItemData::new()
3390            .obs_cont(ObsField::new("campo_cont", "texto_cont"))
3391            .obs_fisco(ObsField::new("campo_fisco", "texto_fisco"));
3392        let item = sample_item().obs_item(obs);
3393        let data = sample_build_data();
3394        let result = build_det(&item, &data).expect("build_det should succeed");
3395
3396        assert!(result.xml.contains("<obsCont xCampo=\"campo_cont\">"));
3397        assert!(result.xml.contains("<obsFisco xCampo=\"campo_fisco\">"));
3398    }
3399
3400    // ── DFeReferenciado ─────────────────────────────────────────────────
3401
3402    #[test]
3403    fn dfe_referenciado_without_n_item() {
3404        use crate::types::DFeReferenciadoData;
3405        let dfe = DFeReferenciadoData::new("12345678901234567890123456789012345678901234");
3406        let item = sample_item().dfe_referenciado(dfe);
3407        let data = sample_build_data();
3408        let result = build_det(&item, &data).expect("build_det should succeed");
3409
3410        assert!(result.xml.contains("<DFeReferenciado>"));
3411        assert!(
3412            result.xml.contains(
3413                "<chaveAcesso>12345678901234567890123456789012345678901234</chaveAcesso>"
3414            )
3415        );
3416        assert!(!result.xml.contains("<nItem>"));
3417    }
3418
3419    #[test]
3420    fn dfe_referenciado_with_n_item() {
3421        use crate::types::DFeReferenciadoData;
3422        let dfe =
3423            DFeReferenciadoData::new("12345678901234567890123456789012345678901234").n_item("3");
3424        let item = sample_item().dfe_referenciado(dfe);
3425        let data = sample_build_data();
3426        let result = build_det(&item, &data).expect("build_det should succeed");
3427
3428        assert!(result.xml.contains("<DFeReferenciado>"));
3429        assert!(result.xml.contains("<nItem>3</nItem>"));
3430    }
3431
3432    // ── Homologation xProd substitution (NFC-e) ─────────────────────────
3433
3434    #[test]
3435    fn nfce_homologation_substitutes_xprod_for_item_1() {
3436        let item = sample_item();
3437        let mut data = sample_build_data();
3438        data.model = InvoiceModel::Nfce;
3439        data.environment = SefazEnvironment::Homologation;
3440        let result = build_det(&item, &data).expect("build_det should succeed");
3441
3442        assert!(result.xml.contains(HOMOLOGATION_XPROD));
3443    }
3444
3445    #[test]
3446    fn nfce_homologation_does_not_substitute_for_item_2() {
3447        let mut item = sample_item();
3448        item.item_number = 2;
3449        let mut data = sample_build_data();
3450        data.model = InvoiceModel::Nfce;
3451        data.environment = SefazEnvironment::Homologation;
3452        let result = build_det(&item, &data).expect("build_det should succeed");
3453
3454        assert!(!result.xml.contains(HOMOLOGATION_XPROD));
3455        assert!(result.xml.contains("<xProd>Gasolina Comum</xProd>"));
3456    }
3457
3458    // ── v_frete, v_seg, v_desc, v_outro ─────────────────────────────────
3459
3460    #[test]
3461    fn optional_value_fields_in_det_result() {
3462        let item = sample_item()
3463            .v_frete(Cents(1000))
3464            .v_seg(Cents(500))
3465            .v_desc(Cents(200))
3466            .v_outro(Cents(300));
3467        let data = sample_build_data();
3468        let result = build_det(&item, &data).expect("build_det should succeed");
3469
3470        assert!(result.xml.contains("<vFrete>10.00</vFrete>"));
3471        assert!(result.xml.contains("<vSeg>5.00</vSeg>"));
3472        assert!(result.xml.contains("<vDesc>2.00</vDesc>"));
3473        assert!(result.xml.contains("<vOutro>3.00</vOutro>"));
3474        assert_eq!(result.v_frete, 1000);
3475        assert_eq!(result.v_seg, 500);
3476        assert_eq!(result.v_desc, 200);
3477        assert_eq!(result.v_outro, 300);
3478    }
3479
3480    // ── ind_tot override ────────────────────────────────────────────────
3481
3482    #[test]
3483    fn ind_tot_zero_excludes_from_total() {
3484        let item = sample_item().ind_tot(0);
3485        let data = sample_build_data();
3486        let result = build_det(&item, &data).expect("build_det should succeed");
3487
3488        assert!(result.xml.contains("<indTot>0</indTot>"));
3489        assert_eq!(result.ind_tot, 0);
3490    }
3491
3492    // ── v_tot_trib ──────────────────────────────────────────────────────
3493
3494    #[test]
3495    fn v_tot_trib_propagated_to_result() {
3496        let item = sample_item().v_tot_trib(Cents(1234));
3497        let data = sample_build_data();
3498        let result = build_det(&item, &data).expect("build_det should succeed");
3499
3500        assert_eq!(result.v_tot_trib, 1234);
3501    }
3502
3503    // ── xPed ────────────────────────────────────────────────────────────
3504
3505    #[test]
3506    fn x_ped_produces_correct_xml() {
3507        let item = sample_item().x_ped("PEDIDO-001");
3508        let data = sample_build_data();
3509        let result = build_det(&item, &data).expect("build_det should succeed");
3510
3511        assert!(result.xml.contains("<xPed>PEDIDO-001</xPed>"));
3512    }
3513
3514    // ── infAdProd ───────────────────────────────────────────────────────
3515
3516    #[test]
3517    fn inf_ad_prod_produces_correct_xml() {
3518        let item = sample_item().inf_ad_prod("informacao adicional do produto");
3519        let data = sample_build_data();
3520        let result = build_det(&item, &data).expect("build_det should succeed");
3521
3522        assert!(
3523            result
3524                .xml
3525                .contains("<infAdProd>informacao adicional do produto</infAdProd>")
3526        );
3527    }
3528
3529    // ── ind_deduz_deson ─────────────────────────────────────────────────
3530
3531    #[test]
3532    fn ind_deduz_deson_true_when_set_to_1() {
3533        let item = InvoiceItemData::new(
3534            1,
3535            "001",
3536            "Produto",
3537            "27101259",
3538            "5102",
3539            "UN",
3540            1.0,
3541            Cents(1000),
3542            Cents(1000),
3543            "40",
3544            Rate(0),
3545            Cents(0),
3546            "99",
3547            "99",
3548        )
3549        .icms_v_icms_deson(Cents(100))
3550        .icms_mot_des_icms(1)
3551        .icms_ind_deduz_deson("1");
3552        let data = normal_build_data();
3553        let result = build_det(&item, &data).expect("build_det should succeed");
3554        assert!(result.ind_deduz_deson);
3555    }
3556
3557    #[test]
3558    fn ind_deduz_deson_false_when_not_set() {
3559        let item = sample_item();
3560        let data = sample_build_data();
3561        let result = build_det(&item, &data).expect("build_det should succeed");
3562        assert!(!result.ind_deduz_deson);
3563    }
3564
3565    // ── orig override ───────────────────────────────────────────────────
3566
3567    #[test]
3568    fn custom_orig_used_in_icms() {
3569        let item = InvoiceItemData::new(
3570            1,
3571            "001",
3572            "Produto",
3573            "27101259",
3574            "5102",
3575            "UN",
3576            1.0,
3577            Cents(1000),
3578            Cents(1000),
3579            "00",
3580            Rate(1800),
3581            Cents(180),
3582            "99",
3583            "99",
3584        )
3585        .orig("1");
3586        let data = normal_build_data();
3587        let result = build_det(&item, &data).expect("build_det should succeed");
3588
3589        assert!(result.xml.contains("<orig>1</orig>"));
3590    }
3591
3592    // ── tpCredPresIBSZFM (PL010 only, inside <prod>) ────────────────────
3593
3594    #[test]
3595    fn tp_cred_pres_ibs_zfm_emitted_with_pl010_schema() {
3596        let item = sample_item()
3597            .tp_cred_pres_ibs_zfm("1")
3598            .c_benef("SEM CBENEF");
3599        let data = pl010_build_data();
3600        let result = build_det(&item, &data).expect("build_det should succeed");
3601
3602        assert!(
3603            result
3604                .xml
3605                .contains("<tpCredPresIBSZFM>1</tpCredPresIBSZFM>")
3606        );
3607        // Must appear after cBenef
3608        let cbenef_pos = result.xml.find("<cBenef>").expect("cBenef should exist");
3609        let tp_pos = result
3610            .xml
3611            .find("<tpCredPresIBSZFM>")
3612            .expect("tpCredPresIBSZFM should exist");
3613        assert!(
3614            tp_pos > cbenef_pos,
3615            "tpCredPresIBSZFM must come after cBenef"
3616        );
3617    }
3618
3619    #[test]
3620    fn tp_cred_pres_ibs_zfm_not_emitted_with_pl009_schema() {
3621        let item = sample_item().tp_cred_pres_ibs_zfm("1");
3622        let data = sample_build_data(); // PL009
3623        let result = build_det(&item, &data).expect("build_det should succeed");
3624
3625        assert!(
3626            !result.xml.contains("<tpCredPresIBSZFM>"),
3627            "tpCredPresIBSZFM must not be emitted with PL009"
3628        );
3629    }
3630
3631    #[test]
3632    fn tp_cred_pres_ibs_zfm_not_emitted_when_not_set() {
3633        let item = sample_item();
3634        let data = pl010_build_data();
3635        let result = build_det(&item, &data).expect("build_det should succeed");
3636
3637        assert!(
3638            !result.xml.contains("<tpCredPresIBSZFM>"),
3639            "tpCredPresIBSZFM must not be emitted when not set"
3640        );
3641    }
3642
3643    #[test]
3644    fn tp_cred_pres_ibs_zfm_position_after_cbenef_before_gcred() {
3645        let gc = GCredData::new("ABC1234567", Rate4(2500)).v_cred_presumido(Cents(500));
3646        let item = sample_item()
3647            .c_benef("SEM CBENEF")
3648            .tp_cred_pres_ibs_zfm("2")
3649            .g_cred(vec![gc]);
3650        let data = pl010_build_data();
3651        let result = build_det(&item, &data).expect("build_det should succeed");
3652
3653        let cbenef_pos = result.xml.find("<cBenef>").expect("cBenef should exist");
3654        let tp_pos = result
3655            .xml
3656            .find("<tpCredPresIBSZFM>")
3657            .expect("tpCredPresIBSZFM should exist");
3658        let gcred_pos = result.xml.find("<gCred>").expect("gCred should exist");
3659        assert!(
3660            tp_pos > cbenef_pos && tp_pos < gcred_pos,
3661            "tpCredPresIBSZFM must be between cBenef and gCred"
3662        );
3663    }
3664
3665    // ── vItem (PL010 only, inside <det>) ─────────────────────────────────
3666
3667    #[test]
3668    fn v_item_emitted_with_pl010_and_ibs_cbs() {
3669        use crate::tax_ibs_cbs::IbsCbsData;
3670        let ibs_cbs = IbsCbsData::new("00", "001");
3671        let item = sample_item().ibs_cbs(ibs_cbs);
3672        let mut data = pl010_build_data();
3673        // At least one item in the invoice must have IBS/CBS
3674        data.items = vec![item.clone()];
3675        let result = build_det(&item, &data).expect("build_det should succeed");
3676
3677        assert!(
3678            result.xml.contains("<vItem>"),
3679            "vItem must be emitted with PL010 and IBS/CBS data"
3680        );
3681    }
3682
3683    #[test]
3684    fn v_item_not_emitted_with_pl009() {
3685        use crate::tax_ibs_cbs::IbsCbsData;
3686        let ibs_cbs = IbsCbsData::new("00", "001");
3687        let item = sample_item().ibs_cbs(ibs_cbs);
3688        let mut data = sample_build_data(); // PL009
3689        data.items = vec![item.clone()];
3690        let result = build_det(&item, &data).expect("build_det should succeed");
3691
3692        assert!(
3693            !result.xml.contains("<vItem>"),
3694            "vItem must not be emitted with PL009"
3695        );
3696    }
3697
3698    #[test]
3699    fn v_item_not_emitted_without_ibs_cbs() {
3700        let item = sample_item();
3701        let mut data = pl010_build_data();
3702        data.items = vec![item.clone()];
3703        let result = build_det(&item, &data).expect("build_det should succeed");
3704
3705        assert!(
3706            !result.xml.contains("<vItem>"),
3707            "vItem must not be emitted without any IBS/CBS data"
3708        );
3709    }
3710
3711    #[test]
3712    fn v_item_auto_calculated_from_values() {
3713        use crate::tax_ibs_cbs::IbsCbsData;
3714        // vProd=1000, vDesc=100, vFrete=50, vSeg=30, vOutro=20
3715        // Expected: 1000 - 100 + 50 + 30 + 20 = 1000 cents = 10.00
3716        let ibs_cbs = IbsCbsData::new("00", "001");
3717        let item = InvoiceItemData::new(
3718            1,
3719            "001",
3720            "Produto",
3721            "27101259",
3722            "5102",
3723            "UN",
3724            1.0,
3725            Cents(1000),
3726            Cents(1000), // vProd
3727            "102",       // CSOSN for Simples Nacional
3728            Rate(1800),
3729            Cents(180),
3730            "99",
3731            "99",
3732        )
3733        .v_desc(Cents(100))
3734        .v_frete(Cents(50))
3735        .v_seg(Cents(30))
3736        .v_outro(Cents(20))
3737        .ibs_cbs(ibs_cbs);
3738        let mut data = pl010_build_data();
3739        data.items = vec![item.clone()];
3740        let result = build_det(&item, &data).expect("build_det should succeed");
3741
3742        assert!(
3743            result.xml.contains("<vItem>10.00</vItem>"),
3744            "vItem should be auto-calculated as 10.00, got xml: {}",
3745            result.xml
3746        );
3747    }
3748
3749    #[test]
3750    fn v_item_user_supplied_takes_precedence() {
3751        use crate::tax_ibs_cbs::IbsCbsData;
3752        let ibs_cbs = IbsCbsData::new("00", "001");
3753        let item = sample_item().ibs_cbs(ibs_cbs).v_item(Cents(9999)); // 99.99
3754        let mut data = pl010_build_data();
3755        data.items = vec![item.clone()];
3756        let result = build_det(&item, &data).expect("build_det should succeed");
3757
3758        assert!(
3759            result.xml.contains("<vItem>99.99</vItem>"),
3760            "vItem should use user-supplied value 99.99"
3761        );
3762    }
3763
3764    #[test]
3765    fn v_item_position_after_obs_item_before_dfe_referenciado() {
3766        use crate::tax_ibs_cbs::IbsCbsData;
3767        use crate::types::{DFeReferenciadoData, ObsField, ObsItemData};
3768        let ibs_cbs = IbsCbsData::new("00", "001");
3769        let obs = ObsItemData::new().obs_cont(ObsField::new("campo1", "texto1"));
3770        let dfe = DFeReferenciadoData::new("12345678901234567890123456789012345678901234");
3771        let item = sample_item()
3772            .ibs_cbs(ibs_cbs)
3773            .obs_item(obs)
3774            .dfe_referenciado(dfe)
3775            .v_item(Cents(5000));
3776        let mut data = pl010_build_data();
3777        data.items = vec![item.clone()];
3778        let result = build_det(&item, &data).expect("build_det should succeed");
3779
3780        let obs_pos = result.xml.find("<obsItem>").expect("obsItem should exist");
3781        let v_item_pos = result.xml.find("<vItem>").expect("vItem should exist");
3782        let dfe_pos = result
3783            .xml
3784            .find("<DFeReferenciado>")
3785            .expect("DFeReferenciado should exist");
3786        assert!(
3787            v_item_pos > obs_pos && v_item_pos < dfe_pos,
3788            "vItem must be between obsItem and DFeReferenciado"
3789        );
3790    }
3791
3792    #[test]
3793    fn v_item_emitted_for_item_without_ibs_cbs_when_another_item_has_it() {
3794        use crate::tax_ibs_cbs::IbsCbsData;
3795        // item1 has no IBS/CBS, but item2 does
3796        let item1 = sample_item();
3797        let ibs_cbs = IbsCbsData::new("00", "001");
3798        let item2 = InvoiceItemData::new(
3799            2,
3800            "002",
3801            "Produto 2",
3802            "27101259",
3803            "5102",
3804            "UN",
3805            1.0,
3806            Cents(500),
3807            Cents(500),
3808            "00",
3809            Rate(1800),
3810            Cents(90),
3811            "99",
3812            "99",
3813        )
3814        .ibs_cbs(ibs_cbs);
3815        let mut data = pl010_build_data();
3816        data.items = vec![item1.clone(), item2.clone()];
3817        // Build item1 (no ibs_cbs) — should still emit vItem because another item has it
3818        let result = build_det(&item1, &data).expect("build_det should succeed");
3819
3820        assert!(
3821            result.xml.contains("<vItem>"),
3822            "vItem must be emitted even for items without IBS/CBS when another item has it"
3823        );
3824    }
3825}