Skip to main content

fiscal_core/xml_builder/
det.rs

1//! Build `<det>` (item detail) elements of the NF-e XML.
2
3use crate::FiscalError;
4use crate::format_utils::{format_cents, format_decimal, format_rate4};
5use crate::newtypes::{Cents, Rate, Rate4};
6use crate::tax_ibs_cbs;
7use crate::tax_icms::{self, IcmsCsosn, IcmsCst, IcmsTotals, IcmsVariant};
8use crate::tax_is;
9use crate::tax_issqn;
10use crate::tax_pis_cofins_ipi::{self, CofinsData, IiData, IpiData, PisData};
11use crate::types::{
12    CombData, InvoiceBuildData, InvoiceItemData, InvoiceModel, SefazEnvironment, TaxRegime,
13};
14use crate::xml_utils::{TagContent, tag};
15
16/// Constant used when emitting NFC-e in homologation environment (first item only).
17const HOMOLOGATION_XPROD: &str =
18    "NOTA FISCAL EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL";
19
20/// Result from building a single `<det>` element.
21#[derive(Debug, Clone)]
22pub struct DetResult {
23    /// The serialised `<det>` XML string.
24    pub xml: String,
25    /// Accumulated ICMS totals contributed by this item.
26    pub icms_totals: IcmsTotals,
27    /// IPI value in cents contributed by this item.
28    pub v_ipi: i64,
29    /// PIS value in cents contributed by this item.
30    pub v_pis: i64,
31    /// COFINS value in cents contributed by this item.
32    pub v_cofins: i64,
33    /// II (import tax) value in cents contributed by this item.
34    pub v_ii: i64,
35    /// Freight value in cents for this item.
36    pub v_frete: i64,
37    /// Insurance value in cents for this item.
38    pub v_seg: i64,
39    /// Discount value in cents for this item.
40    pub v_desc: i64,
41    /// Other expenses value in cents for this item.
42    pub v_outro: i64,
43    /// Whether this item counts towards the invoice total (indTot).
44    pub ind_tot: u8,
45    /// Approximate total tax for this item (`vTotTrib`). Optional.
46    pub v_tot_trib: i64,
47    /// IPI devolution value in cents contributed by this item.
48    pub v_ipi_devol: i64,
49    /// PIS-ST value in cents contributed by this item (only when indSomaPISST = 1).
50    pub v_pis_st: i64,
51    /// COFINS-ST value in cents contributed by this item (only when indSomaCOFINSST = 1).
52    pub v_cofins_st: i64,
53    /// Whether this item has indDeduzDeson = 1 (desoneration deduction applies).
54    pub ind_deduz_deson: bool,
55    /// Whether this item uses ISSQN instead of ICMS.
56    pub has_issqn: bool,
57}
58
59/// Map an invoice item's ICMS fields to the correct typed [`IcmsVariant`].
60fn build_icms_variant(
61    item: &InvoiceItemData,
62    is_simples: bool,
63) -> Result<IcmsVariant, FiscalError> {
64    let orig = item.orig.clone().unwrap_or_else(|| "0".to_string());
65
66    if is_simples {
67        let csosn_code = if item.icms_cst.is_empty() {
68            "102"
69        } else {
70            item.icms_cst.as_str()
71        };
72
73        let csosn = match csosn_code {
74            "101" => IcmsCsosn::Csosn101 {
75                orig,
76                csosn: csosn_code.to_string(),
77                p_cred_sn: item.icms_p_cred_sn.ok_or_else(|| {
78                    FiscalError::MissingRequiredField {
79                        field: "pCredSN".to_string(),
80                    }
81                })?,
82                v_cred_icms_sn: item.icms_v_cred_icms_sn.ok_or_else(|| {
83                    FiscalError::MissingRequiredField {
84                        field: "vCredICMSSN".to_string(),
85                    }
86                })?,
87            },
88            "102" => IcmsCsosn::Csosn102 {
89                orig,
90                csosn: csosn_code.to_string(),
91            },
92            "103" => IcmsCsosn::Csosn103 {
93                orig,
94                csosn: csosn_code.to_string(),
95            },
96            "300" => IcmsCsosn::Csosn300 {
97                orig,
98                csosn: csosn_code.to_string(),
99            },
100            "400" => IcmsCsosn::Csosn400 {
101                orig,
102                csosn: csosn_code.to_string(),
103            },
104            "201" => IcmsCsosn::Csosn201 {
105                orig,
106                csosn: csosn_code.to_string(),
107                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
108                    FiscalError::MissingRequiredField {
109                        field: "modBCST".to_string(),
110                    }
111                })?,
112                p_mva_st: item.icms_p_mva_st,
113                p_red_bc_st: item.icms_red_bc_st,
114                v_bc_st: item
115                    .icms_v_bc_st
116                    .ok_or_else(|| FiscalError::MissingRequiredField {
117                        field: "vBCST".to_string(),
118                    })?,
119                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
120                    FiscalError::MissingRequiredField {
121                        field: "pICMSST".to_string(),
122                    }
123                })?,
124                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
125                    FiscalError::MissingRequiredField {
126                        field: "vICMSST".to_string(),
127                    }
128                })?,
129                v_bc_fcp_st: item.icms_v_bc_fcp_st,
130                p_fcp_st: item.icms_p_fcp_st,
131                v_fcp_st: item.icms_v_fcp_st,
132                p_cred_sn: item.icms_p_cred_sn,
133                v_cred_icms_sn: item.icms_v_cred_icms_sn,
134            },
135            "202" => IcmsCsosn::Csosn202 {
136                orig,
137                csosn: csosn_code.to_string(),
138                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
139                    FiscalError::MissingRequiredField {
140                        field: "modBCST".to_string(),
141                    }
142                })?,
143                p_mva_st: item.icms_p_mva_st,
144                p_red_bc_st: item.icms_red_bc_st,
145                v_bc_st: item
146                    .icms_v_bc_st
147                    .ok_or_else(|| FiscalError::MissingRequiredField {
148                        field: "vBCST".to_string(),
149                    })?,
150                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
151                    FiscalError::MissingRequiredField {
152                        field: "pICMSST".to_string(),
153                    }
154                })?,
155                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
156                    FiscalError::MissingRequiredField {
157                        field: "vICMSST".to_string(),
158                    }
159                })?,
160                v_bc_fcp_st: item.icms_v_bc_fcp_st,
161                p_fcp_st: item.icms_p_fcp_st,
162                v_fcp_st: item.icms_v_fcp_st,
163            },
164            "203" => IcmsCsosn::Csosn203 {
165                orig,
166                csosn: csosn_code.to_string(),
167                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
168                    FiscalError::MissingRequiredField {
169                        field: "modBCST".to_string(),
170                    }
171                })?,
172                p_mva_st: item.icms_p_mva_st,
173                p_red_bc_st: item.icms_red_bc_st,
174                v_bc_st: item
175                    .icms_v_bc_st
176                    .ok_or_else(|| FiscalError::MissingRequiredField {
177                        field: "vBCST".to_string(),
178                    })?,
179                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
180                    FiscalError::MissingRequiredField {
181                        field: "pICMSST".to_string(),
182                    }
183                })?,
184                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
185                    FiscalError::MissingRequiredField {
186                        field: "vICMSST".to_string(),
187                    }
188                })?,
189                v_bc_fcp_st: item.icms_v_bc_fcp_st,
190                p_fcp_st: item.icms_p_fcp_st,
191                v_fcp_st: item.icms_v_fcp_st,
192            },
193            "500" => IcmsCsosn::Csosn500 {
194                orig,
195                csosn: csosn_code.to_string(),
196                v_bc_st_ret: None,
197                p_st: None,
198                v_icms_substituto: item.icms_v_icms_substituto,
199                v_icms_st_ret: None,
200                v_bc_fcp_st_ret: None,
201                p_fcp_st_ret: None,
202                v_fcp_st_ret: None,
203                p_red_bc_efet: None,
204                v_bc_efet: None,
205                p_icms_efet: None,
206                v_icms_efet: None,
207            },
208            "900" => IcmsCsosn::Csosn900 {
209                orig,
210                csosn: csosn_code.to_string(),
211                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
212                v_bc: Some(item.total_price),
213                p_red_bc: item.icms_red_bc,
214                p_icms: Some(item.icms_rate),
215                v_icms: Some(item.icms_amount),
216                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()),
217                p_mva_st: item.icms_p_mva_st,
218                p_red_bc_st: item.icms_red_bc_st,
219                v_bc_st: item.icms_v_bc_st,
220                p_icms_st: item.icms_p_icms_st,
221                v_icms_st: item.icms_v_icms_st,
222                v_bc_fcp_st: item.icms_v_bc_fcp_st,
223                p_fcp_st: item.icms_p_fcp_st,
224                v_fcp_st: item.icms_v_fcp_st,
225                p_cred_sn: item.icms_p_cred_sn,
226                v_cred_icms_sn: item.icms_v_cred_icms_sn,
227            },
228            other => return Err(FiscalError::UnsupportedIcmsCsosn(other.to_string())),
229        };
230        Ok(csosn.into())
231    } else {
232        let cst_code = item.icms_cst.as_str();
233        let cst = match cst_code {
234            "00" => IcmsCst::Cst00 {
235                orig,
236                mod_bc: item
237                    .icms_mod_bc
238                    .map(|v| v.to_string())
239                    .unwrap_or_else(|| "3".to_string()),
240                v_bc: item.total_price,
241                p_icms: item.icms_rate,
242                v_icms: item.icms_amount,
243                p_fcp: item.icms_p_fcp,
244                v_fcp: item.icms_v_fcp,
245            },
246            "10" => IcmsCst::Cst10 {
247                orig,
248                mod_bc: item
249                    .icms_mod_bc
250                    .map(|v| v.to_string())
251                    .unwrap_or_else(|| "3".to_string()),
252                v_bc: item.total_price,
253                p_icms: item.icms_rate,
254                v_icms: item.icms_amount,
255                v_bc_fcp: item.icms_v_bc_fcp,
256                p_fcp: item.icms_p_fcp,
257                v_fcp: item.icms_v_fcp,
258                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
259                    FiscalError::MissingRequiredField {
260                        field: "modBCST".to_string(),
261                    }
262                })?,
263                p_mva_st: item.icms_p_mva_st,
264                p_red_bc_st: item.icms_red_bc_st,
265                v_bc_st: item
266                    .icms_v_bc_st
267                    .ok_or_else(|| FiscalError::MissingRequiredField {
268                        field: "vBCST".to_string(),
269                    })?,
270                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
271                    FiscalError::MissingRequiredField {
272                        field: "pICMSST".to_string(),
273                    }
274                })?,
275                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
276                    FiscalError::MissingRequiredField {
277                        field: "vICMSST".to_string(),
278                    }
279                })?,
280                v_bc_fcp_st: item.icms_v_bc_fcp_st,
281                p_fcp_st: item.icms_p_fcp_st,
282                v_fcp_st: item.icms_v_fcp_st,
283                v_icms_st_deson: None,
284                mot_des_icms_st: None,
285            },
286            "20" => IcmsCst::Cst20 {
287                orig,
288                mod_bc: item
289                    .icms_mod_bc
290                    .map(|v| v.to_string())
291                    .unwrap_or_else(|| "3".to_string()),
292                p_red_bc: item.icms_red_bc.unwrap_or(Rate(0)),
293                v_bc: item.total_price,
294                p_icms: item.icms_rate,
295                v_icms: item.icms_amount,
296                v_bc_fcp: item.icms_v_bc_fcp,
297                p_fcp: item.icms_p_fcp,
298                v_fcp: item.icms_v_fcp,
299                v_icms_deson: item.icms_v_icms_deson,
300                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
301                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
302            },
303            "30" => IcmsCst::Cst30 {
304                orig,
305                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
306                    FiscalError::MissingRequiredField {
307                        field: "modBCST".to_string(),
308                    }
309                })?,
310                p_mva_st: item.icms_p_mva_st,
311                p_red_bc_st: item.icms_red_bc_st,
312                v_bc_st: item
313                    .icms_v_bc_st
314                    .ok_or_else(|| FiscalError::MissingRequiredField {
315                        field: "vBCST".to_string(),
316                    })?,
317                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
318                    FiscalError::MissingRequiredField {
319                        field: "pICMSST".to_string(),
320                    }
321                })?,
322                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
323                    FiscalError::MissingRequiredField {
324                        field: "vICMSST".to_string(),
325                    }
326                })?,
327                v_bc_fcp_st: item.icms_v_bc_fcp_st,
328                p_fcp_st: item.icms_p_fcp_st,
329                v_fcp_st: item.icms_v_fcp_st,
330                v_icms_deson: item.icms_v_icms_deson,
331                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
332                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
333            },
334            "40" => IcmsCst::Cst40 {
335                orig,
336                v_icms_deson: item.icms_v_icms_deson,
337                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
338                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
339            },
340            "41" => IcmsCst::Cst41 {
341                orig,
342                v_icms_deson: item.icms_v_icms_deson,
343                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
344                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
345            },
346            "50" => IcmsCst::Cst50 {
347                orig,
348                v_icms_deson: item.icms_v_icms_deson,
349                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
350                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
351            },
352            "51" => IcmsCst::Cst51 {
353                orig,
354                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
355                p_red_bc: item.icms_red_bc,
356                c_benef_rbc: None,
357                v_bc: Some(item.total_price),
358                p_icms: Some(item.icms_rate),
359                v_icms_op: None,
360                p_dif: None,
361                v_icms_dif: None,
362                v_icms: Some(item.icms_amount),
363                v_bc_fcp: item.icms_v_bc_fcp,
364                p_fcp: item.icms_p_fcp,
365                v_fcp: item.icms_v_fcp,
366                p_fcp_dif: None,
367                v_fcp_dif: None,
368                v_fcp_efet: None,
369            },
370            "60" => IcmsCst::Cst60 {
371                orig,
372                v_bc_st_ret: None,
373                p_st: None,
374                v_icms_substituto: item.icms_v_icms_substituto,
375                v_icms_st_ret: None,
376                v_bc_fcp_st_ret: None,
377                p_fcp_st_ret: None,
378                v_fcp_st_ret: None,
379                p_red_bc_efet: None,
380                v_bc_efet: None,
381                p_icms_efet: None,
382                v_icms_efet: None,
383            },
384            "70" => IcmsCst::Cst70 {
385                orig,
386                mod_bc: item
387                    .icms_mod_bc
388                    .map(|v| v.to_string())
389                    .unwrap_or_else(|| "3".to_string()),
390                p_red_bc: item.icms_red_bc.unwrap_or(Rate(0)),
391                v_bc: item.total_price,
392                p_icms: item.icms_rate,
393                v_icms: item.icms_amount,
394                v_bc_fcp: item.icms_v_bc_fcp,
395                p_fcp: item.icms_p_fcp,
396                v_fcp: item.icms_v_fcp,
397                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()).ok_or_else(|| {
398                    FiscalError::MissingRequiredField {
399                        field: "modBCST".to_string(),
400                    }
401                })?,
402                p_mva_st: item.icms_p_mva_st,
403                p_red_bc_st: item.icms_red_bc_st,
404                v_bc_st: item
405                    .icms_v_bc_st
406                    .ok_or_else(|| FiscalError::MissingRequiredField {
407                        field: "vBCST".to_string(),
408                    })?,
409                p_icms_st: item.icms_p_icms_st.ok_or_else(|| {
410                    FiscalError::MissingRequiredField {
411                        field: "pICMSST".to_string(),
412                    }
413                })?,
414                v_icms_st: item.icms_v_icms_st.ok_or_else(|| {
415                    FiscalError::MissingRequiredField {
416                        field: "vICMSST".to_string(),
417                    }
418                })?,
419                v_bc_fcp_st: item.icms_v_bc_fcp_st,
420                p_fcp_st: item.icms_p_fcp_st,
421                v_fcp_st: item.icms_v_fcp_st,
422                v_icms_deson: item.icms_v_icms_deson,
423                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
424                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
425                v_icms_st_deson: None,
426                mot_des_icms_st: None,
427            },
428            "90" => IcmsCst::Cst90 {
429                orig,
430                mod_bc: item.icms_mod_bc.map(|v| v.to_string()),
431                v_bc: Some(item.total_price),
432                p_red_bc: item.icms_red_bc,
433                c_benef_rbc: None,
434                p_icms: Some(item.icms_rate),
435                v_icms_op: None,
436                p_dif: None,
437                v_icms_dif: None,
438                v_icms: Some(item.icms_amount),
439                v_bc_fcp: item.icms_v_bc_fcp,
440                p_fcp: item.icms_p_fcp,
441                v_fcp: item.icms_v_fcp,
442                p_fcp_dif: None,
443                v_fcp_dif: None,
444                v_fcp_efet: None,
445                mod_bc_st: item.icms_mod_bc_st.map(|v| v.to_string()),
446                p_mva_st: item.icms_p_mva_st,
447                p_red_bc_st: item.icms_red_bc_st,
448                v_bc_st: item.icms_v_bc_st,
449                p_icms_st: item.icms_p_icms_st,
450                v_icms_st: item.icms_v_icms_st,
451                v_bc_fcp_st: item.icms_v_bc_fcp_st,
452                p_fcp_st: item.icms_p_fcp_st,
453                v_fcp_st: item.icms_v_fcp_st,
454                v_icms_deson: item.icms_v_icms_deson,
455                mot_des_icms: item.icms_mot_des_icms.map(|v| v.to_string()),
456                ind_deduz_deson: item.icms_ind_deduz_deson.clone(),
457                v_icms_st_deson: None,
458                mot_des_icms_st: None,
459            },
460            other => return Err(FiscalError::UnsupportedIcmsCst(other.to_string())),
461        };
462        Ok(cst.into())
463    }
464}
465
466/// Build a `<det nItem="N">` element for one invoice item.
467pub(crate) fn build_det(
468    item: &InvoiceItemData,
469    data: &InvoiceBuildData,
470) -> Result<DetResult, FiscalError> {
471    // Validate NVE: up to 8 per item
472    if item.nve.len() > 8 {
473        return Err(FiscalError::InvalidTaxData(format!(
474            "Item {}: NVE limited to 8 entries, got {}",
475            item.item_number,
476            item.nve.len()
477        )));
478    }
479
480    let is_simples = matches!(
481        data.issuer.tax_regime,
482        TaxRegime::SimplesNacional | TaxRegime::SimplesExcess
483    );
484
485    let has_issqn = item.issqn.is_some();
486
487    // Build ICMS (skipped when item has ISSQN)
488    let mut icms_totals = IcmsTotals::default();
489    let icms_xml = if has_issqn {
490        String::new()
491    } else {
492        let icms_variant = build_icms_variant(item, is_simples)?;
493        tax_icms::build_icms_xml(&icms_variant, &mut icms_totals)?
494    };
495
496    // Build ISSQN (optional — only when item.issqn is set)
497    let issqn_xml = if let Some(ref issqn_data) = item.issqn {
498        tax_issqn::build_issqn_xml(issqn_data)
499    } else {
500        String::new()
501    };
502
503    // Build PIS
504    let pis_xml = tax_pis_cofins_ipi::build_pis_xml(&PisData {
505        cst: item.pis_cst.clone(),
506        v_bc: item.pis_v_bc.or(Some(Cents(0))),
507        p_pis: item.pis_p_pis.or(Some(Rate4(0))),
508        v_pis: item.pis_v_pis.or(Some(Cents(0))),
509        q_bc_prod: item.pis_q_bc_prod,
510        v_aliq_prod: item.pis_v_aliq_prod,
511    });
512
513    // Build COFINS
514    let cofins_xml = tax_pis_cofins_ipi::build_cofins_xml(&CofinsData {
515        cst: item.cofins_cst.clone(),
516        v_bc: item.cofins_v_bc.or(Some(Cents(0))),
517        p_cofins: item.cofins_p_cofins.or(Some(Rate4(0))),
518        v_cofins: item.cofins_v_cofins.or(Some(Cents(0))),
519        q_bc_prod: item.cofins_q_bc_prod,
520        v_aliq_prod: item.cofins_v_aliq_prod,
521    });
522
523    // Build IPI (optional)
524    let mut ipi_xml = String::new();
525    let mut v_ipi = 0i64;
526    if let Some(ref ipi_cst) = item.ipi_cst {
527        ipi_xml = tax_pis_cofins_ipi::build_ipi_xml(&IpiData {
528            cst: ipi_cst.clone(),
529            c_enq: item.ipi_c_enq.clone().unwrap_or_else(|| "999".to_string()),
530            v_bc: item.ipi_v_bc,
531            p_ipi: item.ipi_p_ipi,
532            v_ipi: item.ipi_v_ipi,
533            q_unid: item.ipi_q_unid,
534            v_unid: item.ipi_v_unid,
535            ..IpiData::default()
536        });
537        v_ipi = item.ipi_v_ipi.map(|c| c.0).unwrap_or(0);
538    }
539
540    // Build II (optional)
541    let mut ii_xml = String::new();
542    let mut v_ii = 0i64;
543    if let Some(ii_vbc) = item.ii_v_bc {
544        ii_xml = tax_pis_cofins_ipi::build_ii_xml(&IiData {
545            v_bc: ii_vbc,
546            v_desp_adu: item.ii_v_desp_adu.unwrap_or(Cents(0)),
547            v_ii: item.ii_v_ii.unwrap_or(Cents(0)),
548            v_iof: item.ii_v_iof.unwrap_or(Cents(0)),
549        });
550        v_ii = item.ii_v_ii.map(|c| c.0).unwrap_or(0);
551    }
552
553    // Build prod options (rastro, veicProd, med, arma, comb, nRECOPI)
554    let prod_options = build_prod_options(item);
555
556    // Build PIS-ST (optional)
557    let mut v_pis_st = 0i64;
558    if let Some(ref pis_st_data) = item.pis_st {
559        // Accumulate only when indSomaPISST == 1 (matches PHP)
560        if pis_st_data.ind_soma_pis_st == Some(1) {
561            v_pis_st = pis_st_data.v_pis.0;
562        }
563    }
564
565    // Build COFINS-ST (optional)
566    let mut v_cofins_st = 0i64;
567    if let Some(ref cofins_st_data) = item.cofins_st {
568        // Accumulate only when indSomaCOFINSST == 1 (matches PHP)
569        if cofins_st_data.ind_soma_cofins_st == Some(1) {
570            v_cofins_st = cofins_st_data.v_cofins.0;
571        }
572    }
573
574    // Detect indDeduzDeson from item ICMS data
575    let item_ind_deduz_deson = item
576        .icms_ind_deduz_deson
577        .as_deref()
578        .map(|v| v == "1")
579        .unwrap_or(false);
580
581    // Build det-level extras (infAdProd, obsItem, DFeReferenciado)
582    let det_extras = build_det_extras(item);
583
584    // Assemble imposto
585    let mut imposto_children: Vec<String> = Vec::new();
586    if !icms_xml.is_empty() {
587        imposto_children.push(icms_xml);
588    }
589    if !ipi_xml.is_empty() {
590        imposto_children.push(ipi_xml);
591    }
592    // PIS or PISST (mutually exclusive per PHP sped-nfe)
593    if let Some(ref pis_st) = item.pis_st {
594        imposto_children.push(tax_pis_cofins_ipi::build_pis_st_xml(pis_st));
595    } else {
596        imposto_children.push(pis_xml);
597    }
598    // COFINS or COFINSST (mutually exclusive per PHP sped-nfe)
599    if let Some(ref cofins_st) = item.cofins_st {
600        imposto_children.push(tax_pis_cofins_ipi::build_cofins_st_xml(cofins_st));
601    } else {
602        imposto_children.push(cofins_xml);
603    }
604    if !ii_xml.is_empty() {
605        imposto_children.push(ii_xml);
606    }
607    if !issqn_xml.is_empty() {
608        imposto_children.push(issqn_xml);
609    }
610
611    // Build IS (Imposto Seletivo) -- optional, inside <imposto>
612    // Only emitted when schema is PL_010 or later (matching PHP: $this->schema > 9)
613    if data.schema_version.is_pl010() {
614        if let Some(ref is_data) = item.is_data {
615            imposto_children.push(tax_is::build_is_xml(is_data));
616        }
617
618        // Build IBS/CBS -- optional, inside <imposto>
619        if let Some(ref ibs_cbs_data) = item.ibs_cbs {
620            imposto_children.push(tax_ibs_cbs::build_ibs_cbs_xml(ibs_cbs_data));
621        }
622    }
623
624    // Assemble prod
625    let fc2 = |c: i64| format_cents(c, 2);
626    let fc10 = |c: i64| format_cents(c, 10);
627    let fd4 = |v: f64| format_decimal(v, 4);
628
629    let mut prod_children = vec![
630        tag("cProd", &[], TagContent::Text(&item.product_code)),
631        tag(
632            "cEAN",
633            &[],
634            TagContent::Text(item.c_ean.as_deref().unwrap_or("SEM GTIN")),
635        ),
636    ];
637    if let Some(ref cb) = item.c_barra {
638        prod_children.push(tag("cBarra", &[], TagContent::Text(cb)));
639    }
640    prod_children.push(tag(
641        "xProd",
642        &[],
643        TagContent::Text(
644            // PHP substitutes xProd for item 1 of NFC-e in homologation
645            if item.item_number == 1
646                && data.environment == SefazEnvironment::Homologation
647                && data.model == InvoiceModel::Nfce
648            {
649                HOMOLOGATION_XPROD
650            } else {
651                &item.description
652            },
653        ),
654    ));
655    prod_children.push(tag("NCM", &[], TagContent::Text(&item.ncm)));
656    for nve_code in &item.nve {
657        prod_children.push(tag("NVE", &[], TagContent::Text(nve_code)));
658    }
659    if let Some(ref cest) = item.cest {
660        prod_children.push(tag("CEST", &[], TagContent::Text(cest)));
661        if let Some(ref ind) = item.cest_ind_escala {
662            prod_children.push(tag("indEscala", &[], TagContent::Text(ind)));
663        }
664        if let Some(ref fab) = item.cest_cnpj_fab {
665            prod_children.push(tag("CNPJFab", &[], TagContent::Text(fab)));
666        }
667    }
668    if let Some(ref cb) = item.c_benef {
669        prod_children.push(tag("cBenef", &[], TagContent::Text(cb)));
670    }
671    // gCred (crédito presumido ICMS) — up to 4 per item, inside <prod>
672    for gc in item.g_cred.iter().take(4) {
673        let p_str = format_rate4(gc.p_cred_presumido.0);
674        let mut gc_children = vec![
675            tag(
676                "cCredPresumido",
677                &[],
678                TagContent::Text(&gc.c_cred_presumido),
679            ),
680            tag("pCredPresumido", &[], TagContent::Text(&p_str)),
681        ];
682        if let Some(v) = gc.v_cred_presumido {
683            let v_str = format_cents(v.0, 2);
684            gc_children.push(tag("vCredPresumido", &[], TagContent::Text(&v_str)));
685        }
686        prod_children.push(tag("gCred", &[], TagContent::Children(gc_children)));
687    }
688    if let Some(ref ex) = item.extipi {
689        prod_children.push(tag("EXTIPI", &[], TagContent::Text(ex)));
690    }
691    prod_children.extend([
692        tag("CFOP", &[], TagContent::Text(&item.cfop)),
693        tag("uCom", &[], TagContent::Text(&item.unit_of_measure)),
694        tag("qCom", &[], TagContent::Text(&fd4(item.quantity))),
695        tag("vUnCom", &[], TagContent::Text(&fc10(item.unit_price.0))),
696        tag("vProd", &[], TagContent::Text(&fc2(item.total_price.0))),
697        tag(
698            "cEANTrib",
699            &[],
700            TagContent::Text(item.c_ean_trib.as_deref().unwrap_or("SEM GTIN")),
701        ),
702    ]);
703    if let Some(ref cbt) = item.c_barra_trib {
704        prod_children.push(tag("cBarraTrib", &[], TagContent::Text(cbt)));
705    }
706    let u_trib = item
707        .taxable_unit
708        .as_deref()
709        .unwrap_or(&item.unit_of_measure);
710    let q_trib = item.taxable_quantity.unwrap_or(item.quantity);
711    let v_un_trib = item
712        .taxable_unit_price
713        .map(|c| c.0)
714        .unwrap_or(item.unit_price.0);
715    prod_children.extend([
716        tag("uTrib", &[], TagContent::Text(u_trib)),
717        tag("qTrib", &[], TagContent::Text(&fd4(q_trib))),
718        tag("vUnTrib", &[], TagContent::Text(&fc10(v_un_trib))),
719    ]);
720    if let Some(v) = item.v_frete {
721        prod_children.push(tag("vFrete", &[], TagContent::Text(&fc2(v.0))));
722    }
723    if let Some(v) = item.v_seg {
724        prod_children.push(tag("vSeg", &[], TagContent::Text(&fc2(v.0))));
725    }
726    if let Some(v) = item.v_desc {
727        prod_children.push(tag("vDesc", &[], TagContent::Text(&fc2(v.0))));
728    }
729    if let Some(v) = item.v_outro {
730        prod_children.push(tag("vOutro", &[], TagContent::Text(&fc2(v.0))));
731    }
732    let ind_tot_str = match item.ind_tot {
733        Some(v) => v.to_string(),
734        None => "1".to_string(),
735    };
736    prod_children.push(tag("indTot", &[], TagContent::Text(&ind_tot_str)));
737    if item.ind_bem_movel_usado == Some(true) {
738        prod_children.push(tag("indBemMovelUsado", &[], TagContent::Text("1")));
739    }
740    // DI (Declaração de Importação) — after indTot, before detExport
741    if let Some(ref dis) = item.di {
742        for di in dis.iter().take(100) {
743            prod_children.push(build_di_xml(di));
744        }
745    }
746    // detExport — after DI, before xPed
747    if let Some(ref exports) = item.det_export {
748        for dex in exports.iter().take(500) {
749            prod_children.push(build_det_export_xml(dex));
750        }
751    }
752    if let Some(ref xped) = item.x_ped {
753        prod_children.push(tag("xPed", &[], TagContent::Text(xped)));
754    }
755    if let Some(ref nip) = item.n_item_ped {
756        prod_children.push(tag("nItemPed", &[], TagContent::Text(nip)));
757    }
758    if let Some(ref nfci) = item.n_fci {
759        prod_children.push(tag("nFCI", &[], TagContent::Text(nfci)));
760    }
761    prod_children.extend(prod_options);
762
763    // impostoDevol (after imposto, before infAdProd)
764    let mut v_ipi_devol = 0i64;
765    let imposto_devol_xml = if let Some(ref devol) = item.imposto_devol {
766        v_ipi_devol = devol.v_ipi_devol.0;
767        let p_devol_str = format_decimal(devol.p_devol.0 as f64 / 100.0, 2);
768        let v_ipi_devol_str = format_cents(devol.v_ipi_devol.0, 2);
769        tag(
770            "impostoDevol",
771            &[],
772            TagContent::Children(vec![
773                tag("pDevol", &[], TagContent::Text(&p_devol_str)),
774                tag(
775                    "IPI",
776                    &[],
777                    TagContent::Children(vec![tag(
778                        "vIPIDevol",
779                        &[],
780                        TagContent::Text(&v_ipi_devol_str),
781                    )]),
782                ),
783            ]),
784        )
785    } else {
786        String::new()
787    };
788
789    // Assemble det
790    let nitem = item.item_number.to_string();
791    let mut det_children = vec![
792        tag("prod", &[], TagContent::Children(prod_children)),
793        tag("imposto", &[], TagContent::Children(imposto_children)),
794    ];
795    if !imposto_devol_xml.is_empty() {
796        det_children.push(imposto_devol_xml);
797    }
798    det_children.extend(det_extras);
799
800    let xml = tag(
801        "det",
802        &[("nItem", &nitem)],
803        TagContent::Children(det_children),
804    );
805
806    Ok(DetResult {
807        xml,
808        icms_totals,
809        v_ipi,
810        v_pis: item.pis_v_pis.map(|c| c.0).unwrap_or(0),
811        v_cofins: item.cofins_v_cofins.map(|c| c.0).unwrap_or(0),
812        v_ii,
813        v_frete: item.v_frete.map(|c| c.0).unwrap_or(0),
814        v_seg: item.v_seg.map(|c| c.0).unwrap_or(0),
815        v_desc: item.v_desc.map(|c| c.0).unwrap_or(0),
816        v_outro: item.v_outro.map(|c| c.0).unwrap_or(0),
817        ind_tot: item.ind_tot.unwrap_or(1),
818        v_tot_trib: item.v_tot_trib.map(|c| c.0).unwrap_or(0),
819        v_ipi_devol,
820        v_pis_st,
821        v_cofins_st,
822        ind_deduz_deson: item_ind_deduz_deson,
823        has_issqn,
824    })
825}
826
827fn build_prod_options(item: &InvoiceItemData) -> Vec<String> {
828    let mut opts = Vec::new();
829
830    // rastro (batch tracking)
831    if let Some(ref rastros) = item.rastro {
832        for r in rastros.iter().take(500) {
833            let mut rastro_children = vec![
834                tag("nLote", &[], TagContent::Text(&r.n_lote)),
835                tag("qLote", &[], TagContent::Text(&format_decimal(r.q_lote, 3))),
836                tag("dFab", &[], TagContent::Text(&r.d_fab)),
837                tag("dVal", &[], TagContent::Text(&r.d_val)),
838            ];
839            if let Some(ref agreg) = r.c_agreg {
840                rastro_children.push(tag("cAgreg", &[], TagContent::Text(agreg)));
841            }
842            opts.push(tag("rastro", &[], TagContent::Children(rastro_children)));
843        }
844    }
845
846    // CHOICE group: veicProd, med, arma, nRECOPI (mutually exclusive)
847    if let Some(ref v) = item.veic_prod {
848        opts.push(tag(
849            "veicProd",
850            &[],
851            TagContent::Children(vec![
852                tag("tpOp", &[], TagContent::Text(&v.tp_op)),
853                tag("chassi", &[], TagContent::Text(&v.chassi)),
854                tag("cCor", &[], TagContent::Text(&v.c_cor)),
855                tag("xCor", &[], TagContent::Text(&v.x_cor)),
856                tag("pot", &[], TagContent::Text(&v.pot)),
857                tag("cilin", &[], TagContent::Text(&v.cilin)),
858                tag("pesoL", &[], TagContent::Text(&v.peso_l)),
859                tag("pesoB", &[], TagContent::Text(&v.peso_b)),
860                tag("nSerie", &[], TagContent::Text(&v.n_serie)),
861                tag("tpComb", &[], TagContent::Text(&v.tp_comb)),
862                tag("nMotor", &[], TagContent::Text(&v.n_motor)),
863                tag("CMT", &[], TagContent::Text(&v.cmt)),
864                tag("dist", &[], TagContent::Text(&v.dist)),
865                tag("anoMod", &[], TagContent::Text(&v.ano_mod)),
866                tag("anoFab", &[], TagContent::Text(&v.ano_fab)),
867                tag("tpPint", &[], TagContent::Text(&v.tp_pint)),
868                tag("tpVeic", &[], TagContent::Text(&v.tp_veic)),
869                tag("espVeic", &[], TagContent::Text(&v.esp_veic)),
870                tag("VIN", &[], TagContent::Text(&v.vin)),
871                tag("condVeic", &[], TagContent::Text(&v.cond_veic)),
872                tag("cMod", &[], TagContent::Text(&v.c_mod)),
873                tag("cCorDENATRAN", &[], TagContent::Text(&v.c_cor_denatran)),
874                tag("lota", &[], TagContent::Text(&v.lota)),
875                tag("tpRest", &[], TagContent::Text(&v.tp_rest)),
876            ]),
877        ));
878    } else if let Some(ref m) = item.med {
879        let mut med_children = Vec::new();
880        if let Some(ref code) = m.c_prod_anvisa {
881            med_children.push(tag("cProdANVISA", &[], TagContent::Text(code)));
882        }
883        if let Some(ref reason) = m.x_motivo_isencao {
884            med_children.push(tag("xMotivoIsencao", &[], TagContent::Text(reason)));
885        }
886        med_children.push(tag(
887            "vPMC",
888            &[],
889            TagContent::Text(&format_cents(m.v_pmc.0, 2)),
890        ));
891        opts.push(tag("med", &[], TagContent::Children(med_children)));
892    } else if let Some(ref arms) = item.arma {
893        for a in arms.iter().take(500) {
894            opts.push(tag(
895                "arma",
896                &[],
897                TagContent::Children(vec![
898                    tag("tpArma", &[], TagContent::Text(&a.tp_arma)),
899                    tag("nSerie", &[], TagContent::Text(&a.n_serie)),
900                    tag("nCano", &[], TagContent::Text(&a.n_cano)),
901                    tag("descr", &[], TagContent::Text(&a.descr)),
902                ]),
903            ));
904        }
905    } else if let Some(ref recopi) = item.n_recopi {
906        if !recopi.is_empty() {
907            opts.push(tag("nRECOPI", &[], TagContent::Text(recopi)));
908        }
909    }
910
911    // comb — fuel product data (after the CHOICE group, per NF-e schema order)
912    if let Some(ref comb) = item.comb {
913        opts.push(build_comb_xml(comb));
914    }
915
916    opts
917}
918
919/// Build a single `<DI>` element with its nested `<adi>` children.
920fn build_di_xml(di: &crate::types::DiData) -> String {
921    let mut children = vec![
922        tag("nDI", &[], TagContent::Text(&di.n_di)),
923        tag("dDI", &[], TagContent::Text(&di.d_di)),
924        tag("xLocDesemb", &[], TagContent::Text(&di.x_loc_desemb)),
925        tag("UFDesemb", &[], TagContent::Text(&di.uf_desemb)),
926        tag("dDesemb", &[], TagContent::Text(&di.d_desemb)),
927        tag("tpViaTransp", &[], TagContent::Text(&di.tp_via_transp)),
928    ];
929    if let Some(ref v) = di.v_afrmm {
930        children.push(tag("vAFRMM", &[], TagContent::Text(&format_cents(v.0, 2))));
931    }
932    children.push(tag(
933        "tpIntermedio",
934        &[],
935        TagContent::Text(&di.tp_intermedio),
936    ));
937    if let Some(ref cnpj) = di.cnpj {
938        children.push(tag("CNPJ", &[], TagContent::Text(cnpj)));
939    } else if let Some(ref cpf) = di.cpf {
940        children.push(tag("CPF", &[], TagContent::Text(cpf)));
941    }
942    if let Some(ref uf) = di.uf_terceiro {
943        children.push(tag("UFTerceiro", &[], TagContent::Text(uf)));
944    }
945    children.push(tag("cExportador", &[], TagContent::Text(&di.c_exportador)));
946    // adi children (up to 999 per DI)
947    for adi in di.adi.iter().take(999) {
948        let mut adi_children = Vec::new();
949        if let Some(ref n) = adi.n_adicao {
950            adi_children.push(tag("nAdicao", &[], TagContent::Text(n)));
951        }
952        adi_children.push(tag("nSeqAdic", &[], TagContent::Text(&adi.n_seq_adic)));
953        adi_children.push(tag("cFabricante", &[], TagContent::Text(&adi.c_fabricante)));
954        if let Some(ref v) = adi.v_desc_di {
955            adi_children.push(tag("vDescDI", &[], TagContent::Text(&format_cents(v.0, 2))));
956        }
957        if let Some(ref n) = adi.n_draw {
958            adi_children.push(tag("nDraw", &[], TagContent::Text(n)));
959        }
960        children.push(tag("adi", &[], TagContent::Children(adi_children)));
961    }
962    tag("DI", &[], TagContent::Children(children))
963}
964
965/// Build a single `<detExport>` element with optional `<exportInd>`.
966fn build_det_export_xml(dex: &crate::types::DetExportData) -> String {
967    let mut children = Vec::new();
968    if let Some(ref n) = dex.n_draw {
969        children.push(tag("nDraw", &[], TagContent::Text(n)));
970    }
971    if dex.n_re.is_some() || dex.ch_nfe.is_some() || dex.q_export.is_some() {
972        let mut exp_ind_children = Vec::new();
973        if let Some(ref n) = dex.n_re {
974            exp_ind_children.push(tag("nRE", &[], TagContent::Text(n)));
975        }
976        if let Some(ref ch) = dex.ch_nfe {
977            exp_ind_children.push(tag("chNFe", &[], TagContent::Text(ch)));
978        }
979        if let Some(q) = dex.q_export {
980            exp_ind_children.push(tag("qExport", &[], TagContent::Text(&format_decimal(q, 4))));
981        }
982        children.push(tag(
983            "exportInd",
984            &[],
985            TagContent::Children(exp_ind_children),
986        ));
987    }
988    tag("detExport", &[], TagContent::Children(children))
989}
990
991/// Build the `<comb>` element for fuel products.
992///
993/// Follows the PHP sped-nfe `tagcomb` / `tagencerrante` / `tagorigComb`
994/// structure exactly: cProdANP, descANP, pGLP, pGNn, pGNi, vPart, CODIF,
995/// qTemp, UFCons, CIDE, encerrante, pBio, origComb[].
996fn build_comb_xml(comb: &CombData) -> String {
997    let mut children = vec![
998        tag("cProdANP", &[], TagContent::Text(&comb.c_prod_anp)),
999        tag("descANP", &[], TagContent::Text(&comb.desc_anp)),
1000    ];
1001
1002    if let Some(ref v) = comb.p_glp {
1003        children.push(tag("pGLP", &[], TagContent::Text(v)));
1004    }
1005    if let Some(ref v) = comb.p_gn_n {
1006        children.push(tag("pGNn", &[], TagContent::Text(v)));
1007    }
1008    if let Some(ref v) = comb.p_gn_i {
1009        children.push(tag("pGNi", &[], TagContent::Text(v)));
1010    }
1011    if let Some(ref v) = comb.v_part {
1012        children.push(tag("vPart", &[], TagContent::Text(v)));
1013    }
1014    if let Some(ref v) = comb.codif {
1015        children.push(tag("CODIF", &[], TagContent::Text(v)));
1016    }
1017    if let Some(ref v) = comb.q_temp {
1018        children.push(tag("qTemp", &[], TagContent::Text(v)));
1019    }
1020
1021    children.push(tag("UFCons", &[], TagContent::Text(&comb.uf_cons)));
1022
1023    // CIDE (conditional — only when qBCProd is present)
1024    if let Some(ref cide) = comb.cide {
1025        let cide_children = vec![
1026            tag("qBCProd", &[], TagContent::Text(&cide.q_bc_prod)),
1027            tag("vAliqProd", &[], TagContent::Text(&cide.v_aliq_prod)),
1028            tag("vCIDE", &[], TagContent::Text(&cide.v_cide)),
1029        ];
1030        children.push(tag("CIDE", &[], TagContent::Children(cide_children)));
1031    }
1032
1033    // encerrante
1034    if let Some(ref enc) = comb.encerrante {
1035        let mut enc_children = vec![tag("nBico", &[], TagContent::Text(&enc.n_bico))];
1036        if let Some(ref bomba) = enc.n_bomba {
1037            enc_children.push(tag("nBomba", &[], TagContent::Text(bomba)));
1038        }
1039        enc_children.push(tag("nTanque", &[], TagContent::Text(&enc.n_tanque)));
1040        enc_children.push(tag("vEncIni", &[], TagContent::Text(&enc.v_enc_ini)));
1041        enc_children.push(tag("vEncFin", &[], TagContent::Text(&enc.v_enc_fin)));
1042        children.push(tag("encerrante", &[], TagContent::Children(enc_children)));
1043    }
1044
1045    // pBio
1046    if let Some(ref v) = comb.p_bio {
1047        children.push(tag("pBio", &[], TagContent::Text(v)));
1048    }
1049
1050    // origComb (may be multiple)
1051    if let Some(ref origins) = comb.orig_comb {
1052        for orig in origins {
1053            let orig_children = vec![
1054                tag("indImport", &[], TagContent::Text(&orig.ind_import)),
1055                tag("cUFOrig", &[], TagContent::Text(&orig.c_uf_orig)),
1056                tag("pOrig", &[], TagContent::Text(&orig.p_orig)),
1057            ];
1058            children.push(tag("origComb", &[], TagContent::Children(orig_children)));
1059        }
1060    }
1061
1062    tag("comb", &[], TagContent::Children(children))
1063}
1064
1065fn build_det_extras(item: &InvoiceItemData) -> Vec<String> {
1066    let mut extras = Vec::new();
1067
1068    if let Some(ref info) = item.inf_ad_prod {
1069        extras.push(tag("infAdProd", &[], TagContent::Text(info)));
1070    }
1071
1072    if let Some(ref obs) = item.obs_item {
1073        let mut obs_children = Vec::new();
1074        if let Some(ref cont) = obs.obs_cont {
1075            obs_children.push(tag(
1076                "obsCont",
1077                &[("xCampo", &cont.x_campo)],
1078                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&cont.x_texto))]),
1079            ));
1080        }
1081        if let Some(ref fisco) = obs.obs_fisco {
1082            obs_children.push(tag(
1083                "obsFisco",
1084                &[("xCampo", &fisco.x_campo)],
1085                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&fisco.x_texto))]),
1086            ));
1087        }
1088        extras.push(tag("obsItem", &[], TagContent::Children(obs_children)));
1089    }
1090
1091    if let Some(ref dfe) = item.dfe_referenciado {
1092        let mut dfe_children = vec![tag("chaveAcesso", &[], TagContent::Text(&dfe.chave_acesso))];
1093        if let Some(ref n) = dfe.n_item {
1094            dfe_children.push(tag("nItem", &[], TagContent::Text(n)));
1095        }
1096        extras.push(tag(
1097            "DFeReferenciado",
1098            &[],
1099            TagContent::Children(dfe_children),
1100        ));
1101    }
1102
1103    extras
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109    use crate::newtypes::{Cents, IbgeCode, Rate, Rate4};
1110    use crate::tax_issqn::IssqnData as TaxIssqnData;
1111    use crate::types::{
1112        ArmaData, CideData, CombData, EncerranteData, GCredData, InvoiceItemData, InvoiceModel,
1113        IssuerData, MedData, OrigCombData, RastroData, SefazEnvironment, TaxRegime, VeicProdData,
1114    };
1115
1116    fn sample_build_data() -> InvoiceBuildData {
1117        let issuer = IssuerData::new(
1118            "12345678000199",
1119            "123456789",
1120            "Test Company",
1121            TaxRegime::SimplesNacional,
1122            "SP",
1123            IbgeCode("3550308".to_string()),
1124            "Sao Paulo",
1125            "Av Paulista",
1126            "1000",
1127            "Bela Vista",
1128            "01310100",
1129        );
1130
1131        InvoiceBuildData {
1132            schema_version: crate::types::SchemaVersion::PL009,
1133            model: InvoiceModel::Nfe,
1134            series: 1,
1135            number: 1,
1136            emission_type: crate::types::EmissionType::Normal,
1137            environment: SefazEnvironment::Homologation,
1138            issued_at: chrono::Utc::now()
1139                .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).expect("valid offset")),
1140            operation_nature: "VENDA".to_string(),
1141            issuer,
1142            recipient: None,
1143            items: Vec::new(),
1144            payments: Vec::new(),
1145            change_amount: None,
1146            payment_card_details: None,
1147            contingency: None,
1148            exit_at: None,
1149            operation_type: None,
1150            purpose_code: None,
1151            intermediary_indicator: None,
1152            emission_process: None,
1153            consumer_type: None,
1154            buyer_presence: None,
1155            print_format: None,
1156            references: None,
1157            transport: None,
1158            billing: None,
1159            withdrawal: None,
1160            delivery: None,
1161            authorized_xml: None,
1162            additional_info: None,
1163            intermediary: None,
1164            ret_trib: None,
1165            tech_responsible: None,
1166            purchase: None,
1167            export: None,
1168            issqn_tot: None,
1169            cana: None,
1170            agropecuario: None,
1171            compra_gov: None,
1172            pag_antecipado: None,
1173            is_tot: None,
1174            ibs_cbs_tot: None,
1175            destination_indicator: None,
1176            ver_proc: None,
1177            only_ascii: false,
1178            calculation_method: crate::types::CalculationMethod::V2,
1179        }
1180    }
1181
1182    fn sample_item() -> InvoiceItemData {
1183        InvoiceItemData::new(
1184            1,
1185            "001",
1186            "Gasolina Comum",
1187            "27101259",
1188            "5102",
1189            "LT",
1190            50.0,
1191            Cents(599),
1192            Cents(29950),
1193            "102",
1194            Rate(0),
1195            Cents(0),
1196            "99",
1197            "99",
1198        )
1199    }
1200
1201    // ── Combustíveis ────────────────────────────────────────────────────────
1202
1203    #[test]
1204    fn comb_minimal_produces_correct_xml() {
1205        let comb = CombData::new("210203001", "GLP", "SP");
1206        let xml = build_comb_xml(&comb);
1207
1208        assert_eq!(
1209            xml,
1210            "<comb>\
1211                <cProdANP>210203001</cProdANP>\
1212                <descANP>GLP</descANP>\
1213                <UFCons>SP</UFCons>\
1214            </comb>"
1215        );
1216    }
1217
1218    #[test]
1219    fn comb_with_glp_percentages() {
1220        let comb = CombData::new("210203001", "GLP", "SP")
1221            .p_glp("60.0000")
1222            .p_gn_n("25.0000")
1223            .p_gn_i("15.0000")
1224            .v_part("3.50");
1225
1226        let xml = build_comb_xml(&comb);
1227
1228        assert_eq!(
1229            xml,
1230            "<comb>\
1231                <cProdANP>210203001</cProdANP>\
1232                <descANP>GLP</descANP>\
1233                <pGLP>60.0000</pGLP>\
1234                <pGNn>25.0000</pGNn>\
1235                <pGNi>15.0000</pGNi>\
1236                <vPart>3.50</vPart>\
1237                <UFCons>SP</UFCons>\
1238            </comb>"
1239        );
1240    }
1241
1242    #[test]
1243    fn comb_with_codif_and_qtemp() {
1244        let comb = CombData::new("320102001", "GASOLINA COMUM", "PR")
1245            .codif("123456789")
1246            .q_temp("1000.0000");
1247
1248        let xml = build_comb_xml(&comb);
1249
1250        assert_eq!(
1251            xml,
1252            "<comb>\
1253                <cProdANP>320102001</cProdANP>\
1254                <descANP>GASOLINA COMUM</descANP>\
1255                <CODIF>123456789</CODIF>\
1256                <qTemp>1000.0000</qTemp>\
1257                <UFCons>PR</UFCons>\
1258            </comb>"
1259        );
1260    }
1261
1262    #[test]
1263    fn comb_with_cide() {
1264        let cide = CideData::new("1000.0000", "0.0700", "70.00");
1265        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").cide(cide);
1266
1267        let xml = build_comb_xml(&comb);
1268
1269        assert_eq!(
1270            xml,
1271            "<comb>\
1272                <cProdANP>320102001</cProdANP>\
1273                <descANP>GASOLINA COMUM</descANP>\
1274                <UFCons>SP</UFCons>\
1275                <CIDE>\
1276                    <qBCProd>1000.0000</qBCProd>\
1277                    <vAliqProd>0.0700</vAliqProd>\
1278                    <vCIDE>70.00</vCIDE>\
1279                </CIDE>\
1280            </comb>"
1281        );
1282    }
1283
1284    #[test]
1285    fn comb_with_encerrante() {
1286        let enc = EncerranteData::new("1", "1", "1234.567", "1284.567").n_bomba("2");
1287        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").encerrante(enc);
1288
1289        let xml = build_comb_xml(&comb);
1290
1291        assert_eq!(
1292            xml,
1293            "<comb>\
1294                <cProdANP>320102001</cProdANP>\
1295                <descANP>GASOLINA COMUM</descANP>\
1296                <UFCons>SP</UFCons>\
1297                <encerrante>\
1298                    <nBico>1</nBico>\
1299                    <nBomba>2</nBomba>\
1300                    <nTanque>1</nTanque>\
1301                    <vEncIni>1234.567</vEncIni>\
1302                    <vEncFin>1284.567</vEncFin>\
1303                </encerrante>\
1304            </comb>"
1305        );
1306    }
1307
1308    #[test]
1309    fn comb_encerrante_without_bomba() {
1310        let enc = EncerranteData::new("3", "2", "5000.000", "5050.000");
1311        let comb = CombData::new("320102001", "GASOLINA COMUM", "RJ").encerrante(enc);
1312
1313        let xml = build_comb_xml(&comb);
1314
1315        assert_eq!(
1316            xml,
1317            "<comb>\
1318                <cProdANP>320102001</cProdANP>\
1319                <descANP>GASOLINA COMUM</descANP>\
1320                <UFCons>RJ</UFCons>\
1321                <encerrante>\
1322                    <nBico>3</nBico>\
1323                    <nTanque>2</nTanque>\
1324                    <vEncIni>5000.000</vEncIni>\
1325                    <vEncFin>5050.000</vEncFin>\
1326                </encerrante>\
1327            </comb>"
1328        );
1329    }
1330
1331    #[test]
1332    fn comb_with_pbio() {
1333        let comb = CombData::new("810102001", "OLEO DIESEL B S10", "SP").p_bio("15.0000");
1334
1335        let xml = build_comb_xml(&comb);
1336
1337        assert_eq!(
1338            xml,
1339            "<comb>\
1340                <cProdANP>810102001</cProdANP>\
1341                <descANP>OLEO DIESEL B S10</descANP>\
1342                <UFCons>SP</UFCons>\
1343                <pBio>15.0000</pBio>\
1344            </comb>"
1345        );
1346    }
1347
1348    #[test]
1349    fn comb_with_orig_comb_single() {
1350        let orig = OrigCombData::new("0", "35", "100.0000");
1351        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig]);
1352
1353        let xml = build_comb_xml(&comb);
1354
1355        assert_eq!(
1356            xml,
1357            "<comb>\
1358                <cProdANP>320102001</cProdANP>\
1359                <descANP>GASOLINA COMUM</descANP>\
1360                <UFCons>SP</UFCons>\
1361                <origComb>\
1362                    <indImport>0</indImport>\
1363                    <cUFOrig>35</cUFOrig>\
1364                    <pOrig>100.0000</pOrig>\
1365                </origComb>\
1366            </comb>"
1367        );
1368    }
1369
1370    #[test]
1371    fn comb_with_orig_comb_multiple() {
1372        let orig1 = OrigCombData::new("0", "35", "70.0000");
1373        let orig2 = OrigCombData::new("1", "99", "30.0000");
1374        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP").orig_comb(vec![orig1, orig2]);
1375
1376        let xml = build_comb_xml(&comb);
1377
1378        assert_eq!(
1379            xml,
1380            "<comb>\
1381                <cProdANP>320102001</cProdANP>\
1382                <descANP>GASOLINA COMUM</descANP>\
1383                <UFCons>SP</UFCons>\
1384                <origComb>\
1385                    <indImport>0</indImport>\
1386                    <cUFOrig>35</cUFOrig>\
1387                    <pOrig>70.0000</pOrig>\
1388                </origComb>\
1389                <origComb>\
1390                    <indImport>1</indImport>\
1391                    <cUFOrig>99</cUFOrig>\
1392                    <pOrig>30.0000</pOrig>\
1393                </origComb>\
1394            </comb>"
1395        );
1396    }
1397
1398    #[test]
1399    fn comb_full_with_all_fields() {
1400        let cide = CideData::new("500.0000", "0.0700", "35.00");
1401        let enc = EncerranteData::new("1", "1", "10000.000", "10050.000").n_bomba("1");
1402        let orig = OrigCombData::new("0", "35", "100.0000");
1403
1404        let comb = CombData::new("210203001", "GLP", "SP")
1405            .p_glp("60.0000")
1406            .p_gn_n("25.0000")
1407            .p_gn_i("15.0000")
1408            .v_part("3.50")
1409            .codif("999888777")
1410            .q_temp("500.0000")
1411            .cide(cide)
1412            .encerrante(enc)
1413            .p_bio("12.0000")
1414            .orig_comb(vec![orig]);
1415
1416        let xml = build_comb_xml(&comb);
1417
1418        assert_eq!(
1419            xml,
1420            "<comb>\
1421                <cProdANP>210203001</cProdANP>\
1422                <descANP>GLP</descANP>\
1423                <pGLP>60.0000</pGLP>\
1424                <pGNn>25.0000</pGNn>\
1425                <pGNi>15.0000</pGNi>\
1426                <vPart>3.50</vPart>\
1427                <CODIF>999888777</CODIF>\
1428                <qTemp>500.0000</qTemp>\
1429                <UFCons>SP</UFCons>\
1430                <CIDE>\
1431                    <qBCProd>500.0000</qBCProd>\
1432                    <vAliqProd>0.0700</vAliqProd>\
1433                    <vCIDE>35.00</vCIDE>\
1434                </CIDE>\
1435                <encerrante>\
1436                    <nBico>1</nBico>\
1437                    <nBomba>1</nBomba>\
1438                    <nTanque>1</nTanque>\
1439                    <vEncIni>10000.000</vEncIni>\
1440                    <vEncFin>10050.000</vEncFin>\
1441                </encerrante>\
1442                <pBio>12.0000</pBio>\
1443                <origComb>\
1444                    <indImport>0</indImport>\
1445                    <cUFOrig>35</cUFOrig>\
1446                    <pOrig>100.0000</pOrig>\
1447                </origComb>\
1448            </comb>"
1449        );
1450    }
1451
1452    #[test]
1453    fn comb_in_det_xml() {
1454        let comb = CombData::new("320102001", "GASOLINA COMUM", "SP");
1455        let item = sample_item().comb(comb);
1456        let data = sample_build_data();
1457        let result = build_det(&item, &data).expect("build_det should succeed");
1458
1459        // <comb> appears inside <prod>
1460        let prod_start = result.xml.find("<prod>").expect("<prod> must exist");
1461        let prod_end = result.xml.find("</prod>").expect("</prod> must exist");
1462        let prod_section = &result.xml[prod_start..prod_end];
1463
1464        assert!(prod_section.contains("<comb>"));
1465        assert!(prod_section.contains("<cProdANP>320102001</cProdANP>"));
1466        assert!(prod_section.contains("<descANP>GASOLINA COMUM</descANP>"));
1467        assert!(prod_section.contains("<UFCons>SP</UFCons>"));
1468        assert!(prod_section.contains("</comb>"));
1469    }
1470
1471    // ── ISSQN ───────────────────────────────────────────────────────────────
1472
1473    #[test]
1474    fn issqn_item_produces_issqn_tag_not_icms() {
1475        let issqn_data = TaxIssqnData::new(10000, 500, 500, "3550308", "14.01")
1476            .ind_iss("1")
1477            .ind_incentivo("2");
1478        let item = sample_item().issqn(issqn_data);
1479        let data = sample_build_data();
1480        let result = build_det(&item, &data).expect("build_det should succeed");
1481
1482        // ISSQN tag present inside <imposto>
1483        assert!(result.xml.contains("<ISSQN>"));
1484        assert!(result.xml.contains("<vBC>100.00</vBC>"));
1485        assert!(result.xml.contains("<vAliq>5.0000</vAliq>"));
1486        assert!(result.xml.contains("<vISSQN>5.00</vISSQN>"));
1487        assert!(result.xml.contains("<cMunFG>3550308</cMunFG>"));
1488        assert!(result.xml.contains("<cListServ>14.01</cListServ>"));
1489        assert!(result.xml.contains("<indISS>1</indISS>"));
1490        assert!(result.xml.contains("<indIncentivo>2</indIncentivo>"));
1491        assert!(result.xml.contains("</ISSQN>"));
1492
1493        // ICMS should NOT be present for ISSQN items
1494        assert!(!result.xml.contains("<ICMS>"));
1495        assert!(!result.xml.contains("</ICMS>"));
1496        assert!(result.has_issqn);
1497    }
1498
1499    #[test]
1500    fn issqn_item_with_all_optional_fields() {
1501        let issqn_data = TaxIssqnData::new(20000, 300, 600, "3304557", "07.02")
1502            .v_deducao(1000)
1503            .v_outro(500)
1504            .v_desc_incond(200)
1505            .v_desc_cond(100)
1506            .v_iss_ret(300)
1507            .ind_iss("1")
1508            .c_servico("1234")
1509            .c_mun("3304557")
1510            .c_pais("1058")
1511            .n_processo("ABC123")
1512            .ind_incentivo("1");
1513
1514        let item = sample_item().issqn(issqn_data);
1515        let data = sample_build_data();
1516        let result = build_det(&item, &data).expect("build_det should succeed");
1517
1518        assert!(result.xml.contains("<vBC>200.00</vBC>"));
1519        assert!(result.xml.contains("<vAliq>3.0000</vAliq>"));
1520        assert!(result.xml.contains("<vISSQN>6.00</vISSQN>"));
1521        assert!(result.xml.contains("<vDeducao>10.00</vDeducao>"));
1522        assert!(result.xml.contains("<vOutro>5.00</vOutro>"));
1523        assert!(result.xml.contains("<vDescIncond>2.00</vDescIncond>"));
1524        assert!(result.xml.contains("<vDescCond>1.00</vDescCond>"));
1525        assert!(result.xml.contains("<vISSRet>3.00</vISSRet>"));
1526        assert!(result.xml.contains("<cServico>1234</cServico>"));
1527        assert!(result.xml.contains("<cMun>3304557</cMun>"));
1528        assert!(result.xml.contains("<cPais>1058</cPais>"));
1529        assert!(result.xml.contains("<nProcesso>ABC123</nProcesso>"));
1530        assert!(result.xml.contains("<indIncentivo>1</indIncentivo>"));
1531        assert!(result.has_issqn);
1532    }
1533
1534    #[test]
1535    fn non_issqn_item_has_icms_and_no_issqn() {
1536        let item = sample_item();
1537        let data = sample_build_data();
1538        let result = build_det(&item, &data).expect("build_det should succeed");
1539
1540        assert!(result.xml.contains("<ICMS"));
1541        assert!(!result.xml.contains("<ISSQN>"));
1542        assert!(!result.has_issqn);
1543    }
1544
1545    // ── Declaração de Importação (DI) ──────────────────────────────────────
1546
1547    #[test]
1548    fn di_minimal_with_one_adi() {
1549        use crate::types::{AdiData, DiData};
1550
1551        let adi = AdiData::new("1", "FABRICANTE_X").n_adicao("001");
1552        let di = DiData::new(
1553            "1234567890",
1554            "2025-01-15",
1555            "Santos",
1556            "SP",
1557            "2025-01-20",
1558            "1",
1559            "1",
1560            "EXP001",
1561            vec![adi],
1562        );
1563        let xml = build_di_xml(&di);
1564
1565        assert_eq!(
1566            xml,
1567            "<DI>\
1568                <nDI>1234567890</nDI>\
1569                <dDI>2025-01-15</dDI>\
1570                <xLocDesemb>Santos</xLocDesemb>\
1571                <UFDesemb>SP</UFDesemb>\
1572                <dDesemb>2025-01-20</dDesemb>\
1573                <tpViaTransp>1</tpViaTransp>\
1574                <tpIntermedio>1</tpIntermedio>\
1575                <cExportador>EXP001</cExportador>\
1576                <adi>\
1577                    <nAdicao>001</nAdicao>\
1578                    <nSeqAdic>1</nSeqAdic>\
1579                    <cFabricante>FABRICANTE_X</cFabricante>\
1580                </adi>\
1581            </DI>"
1582        );
1583    }
1584
1585    #[test]
1586    fn di_with_all_optional_fields() {
1587        use crate::types::{AdiData, DiData};
1588
1589        let adi = AdiData::new("1", "FAB_Y")
1590            .n_adicao("002")
1591            .v_desc_di(Cents(15000))
1592            .n_draw("20259999999");
1593        let di = DiData::new(
1594            "DI-2025-001",
1595            "2025-03-01",
1596            "Paranagua",
1597            "PR",
1598            "2025-03-05",
1599            "1",
1600            "2",
1601            "EXP002",
1602            vec![adi],
1603        )
1604        .v_afrmm(Cents(5000))
1605        .cnpj("12345678000199")
1606        .uf_terceiro("RJ");
1607
1608        let xml = build_di_xml(&di);
1609
1610        assert_eq!(
1611            xml,
1612            "<DI>\
1613                <nDI>DI-2025-001</nDI>\
1614                <dDI>2025-03-01</dDI>\
1615                <xLocDesemb>Paranagua</xLocDesemb>\
1616                <UFDesemb>PR</UFDesemb>\
1617                <dDesemb>2025-03-05</dDesemb>\
1618                <tpViaTransp>1</tpViaTransp>\
1619                <vAFRMM>50.00</vAFRMM>\
1620                <tpIntermedio>2</tpIntermedio>\
1621                <CNPJ>12345678000199</CNPJ>\
1622                <UFTerceiro>RJ</UFTerceiro>\
1623                <cExportador>EXP002</cExportador>\
1624                <adi>\
1625                    <nAdicao>002</nAdicao>\
1626                    <nSeqAdic>1</nSeqAdic>\
1627                    <cFabricante>FAB_Y</cFabricante>\
1628                    <vDescDI>150.00</vDescDI>\
1629                    <nDraw>20259999999</nDraw>\
1630                </adi>\
1631            </DI>"
1632        );
1633    }
1634
1635    #[test]
1636    fn di_with_cpf_instead_of_cnpj() {
1637        use crate::types::{AdiData, DiData};
1638
1639        let adi = AdiData::new("1", "FAB_Z");
1640        let di = DiData::new(
1641            "DI-CPF",
1642            "2025-06-01",
1643            "Recife",
1644            "PE",
1645            "2025-06-03",
1646            "7",
1647            "3",
1648            "EXP003",
1649            vec![adi],
1650        )
1651        .cpf("12345678901");
1652
1653        let xml = build_di_xml(&di);
1654        assert!(xml.contains("<CPF>12345678901</CPF>"));
1655        assert!(!xml.contains("<CNPJ>"));
1656    }
1657
1658    #[test]
1659    fn di_with_multiple_adi() {
1660        use crate::types::{AdiData, DiData};
1661
1662        let adi1 = AdiData::new("1", "FAB_A").n_adicao("001");
1663        let adi2 = AdiData::new("2", "FAB_B").n_adicao("001");
1664        let di = DiData::new(
1665            "DI-MULTI",
1666            "2025-01-01",
1667            "Santos",
1668            "SP",
1669            "2025-01-05",
1670            "1",
1671            "1",
1672            "EXP-M",
1673            vec![adi1, adi2],
1674        );
1675        let xml = build_di_xml(&di);
1676
1677        // Both adi elements present
1678        let count = xml.matches("<adi>").count();
1679        assert_eq!(count, 2, "expected 2 <adi> elements, got {count}");
1680        assert!(xml.contains("<nSeqAdic>1</nSeqAdic>"));
1681        assert!(xml.contains("<nSeqAdic>2</nSeqAdic>"));
1682        assert!(xml.contains("<cFabricante>FAB_A</cFabricante>"));
1683        assert!(xml.contains("<cFabricante>FAB_B</cFabricante>"));
1684    }
1685
1686    #[test]
1687    fn di_in_det_xml_between_ind_tot_and_xped() {
1688        use crate::types::{AdiData, DiData};
1689
1690        let adi = AdiData::new("1", "FAB").n_adicao("001");
1691        let di = DiData::new(
1692            "DI-001",
1693            "2025-01-15",
1694            "Santos",
1695            "SP",
1696            "2025-01-20",
1697            "1",
1698            "1",
1699            "EXP",
1700            vec![adi],
1701        );
1702        let item = sample_item().di(vec![di]).x_ped("PO-123");
1703        let data = sample_build_data();
1704        let result = build_det(&item, &data).expect("build_det should succeed");
1705
1706        let xml = &result.xml;
1707        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1708        let di_pos = xml.find("<DI>").expect("<DI> must exist");
1709        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1710
1711        assert!(di_pos > ind_tot_pos, "DI must come after indTot");
1712        assert!(xped_pos > di_pos, "xPed must come after DI");
1713    }
1714
1715    // ── Detalhe de Exportação (detExport) ──────────────────────────────────
1716
1717    #[test]
1718    fn det_export_with_n_draw_only() {
1719        use crate::types::DetExportData;
1720
1721        let dex = DetExportData::new().n_draw("20250000001");
1722        let xml = build_det_export_xml(&dex);
1723
1724        assert_eq!(
1725            xml,
1726            "<detExport>\
1727                <nDraw>20250000001</nDraw>\
1728            </detExport>"
1729        );
1730    }
1731
1732    #[test]
1733    fn det_export_with_export_ind() {
1734        use crate::types::DetExportData;
1735
1736        let dex = DetExportData::new()
1737            .n_draw("20250000002")
1738            .n_re("123456789012")
1739            .ch_nfe("12345678901234567890123456789012345678901234")
1740            .q_export(100.5);
1741        let xml = build_det_export_xml(&dex);
1742
1743        assert_eq!(
1744            xml,
1745            "<detExport>\
1746                <nDraw>20250000002</nDraw>\
1747                <exportInd>\
1748                    <nRE>123456789012</nRE>\
1749                    <chNFe>12345678901234567890123456789012345678901234</chNFe>\
1750                    <qExport>100.5000</qExport>\
1751                </exportInd>\
1752            </detExport>"
1753        );
1754    }
1755
1756    #[test]
1757    fn det_export_empty() {
1758        use crate::types::DetExportData;
1759
1760        let dex = DetExportData::new();
1761        let xml = build_det_export_xml(&dex);
1762
1763        assert_eq!(xml, "<detExport></detExport>");
1764    }
1765
1766    #[test]
1767    fn det_export_in_det_xml_between_ind_tot_and_xped() {
1768        use crate::types::DetExportData;
1769
1770        let dex = DetExportData::new().n_draw("20250000001");
1771        let item = sample_item().det_export(vec![dex]).x_ped("PO-456");
1772        let data = sample_build_data();
1773        let result = build_det(&item, &data).expect("build_det should succeed");
1774
1775        let xml = &result.xml;
1776        let ind_tot_pos = xml.find("</indTot>").expect("</indTot> must exist");
1777        let det_exp_pos = xml.find("<detExport>").expect("<detExport> must exist");
1778        let xped_pos = xml.find("<xPed>").expect("<xPed> must exist");
1779
1780        assert!(
1781            det_exp_pos > ind_tot_pos,
1782            "detExport must come after indTot"
1783        );
1784        assert!(xped_pos > det_exp_pos, "xPed must come after detExport");
1785    }
1786
1787    // ── Imposto Devolvido (impostoDevol) ───────────────────────────────────
1788
1789    #[test]
1790    fn imposto_devol_produces_correct_xml() {
1791        use crate::types::ImpostoDevolData;
1792
1793        let devol = ImpostoDevolData::new(Rate(10000), Cents(5000));
1794        let item = sample_item().imposto_devol(devol);
1795        let data = sample_build_data();
1796        let result = build_det(&item, &data).expect("build_det should succeed");
1797
1798        assert!(result.xml.contains(
1799            "<impostoDevol>\
1800                <pDevol>100.00</pDevol>\
1801                <IPI>\
1802                    <vIPIDevol>50.00</vIPIDevol>\
1803                </IPI>\
1804            </impostoDevol>"
1805        ));
1806        assert_eq!(result.v_ipi_devol, 5000);
1807    }
1808
1809    #[test]
1810    fn imposto_devol_50_percent() {
1811        use crate::types::ImpostoDevolData;
1812
1813        let devol = ImpostoDevolData::new(Rate(5000), Cents(2500));
1814        let item = sample_item().imposto_devol(devol);
1815        let data = sample_build_data();
1816        let result = build_det(&item, &data).expect("build_det should succeed");
1817
1818        assert!(result.xml.contains("<pDevol>50.00</pDevol>"));
1819        assert!(result.xml.contains("<vIPIDevol>25.00</vIPIDevol>"));
1820        assert_eq!(result.v_ipi_devol, 2500);
1821    }
1822
1823    #[test]
1824    fn imposto_devol_after_imposto_before_inf_ad_prod() {
1825        use crate::types::ImpostoDevolData;
1826
1827        let devol = ImpostoDevolData::new(Rate(10000), Cents(1000));
1828        let item = sample_item().imposto_devol(devol).inf_ad_prod("test info");
1829        let data = sample_build_data();
1830        let result = build_det(&item, &data).expect("build_det should succeed");
1831
1832        let imposto_end = result
1833            .xml
1834            .find("</imposto>")
1835            .expect("</imposto> must exist");
1836        let devol_pos = result
1837            .xml
1838            .find("<impostoDevol>")
1839            .expect("<impostoDevol> must exist");
1840        let inf_ad_pos = result
1841            .xml
1842            .find("<infAdProd>")
1843            .expect("<infAdProd> must exist");
1844
1845        assert!(
1846            devol_pos > imposto_end,
1847            "impostoDevol must come after </imposto>"
1848        );
1849        assert!(
1850            inf_ad_pos > devol_pos,
1851            "infAdProd must come after impostoDevol"
1852        );
1853    }
1854
1855    #[test]
1856    fn no_imposto_devol_when_none() {
1857        let item = sample_item();
1858        let data = sample_build_data();
1859        let result = build_det(&item, &data).expect("build_det should succeed");
1860
1861        assert!(!result.xml.contains("<impostoDevol>"));
1862        assert_eq!(result.v_ipi_devol, 0);
1863    }
1864
1865    // ── NVE (Nomenclatura de Valor Aduaneiro e Estatística) ──────────────
1866
1867    #[test]
1868    fn nve_single_code_produces_correct_xml() {
1869        let item = sample_item().nve("AA0001");
1870        let data = sample_build_data();
1871        let result = build_det(&item, &data).expect("build_det should succeed");
1872
1873        assert!(result.xml.contains("<NVE>AA0001</NVE>"));
1874        // NVE must appear after NCM
1875        let ncm_pos = result.xml.find("<NCM>").expect("<NCM> must exist");
1876        let nve_pos = result
1877            .xml
1878            .find("<NVE>AA0001</NVE>")
1879            .expect("<NVE> must exist");
1880        assert!(nve_pos > ncm_pos, "NVE must come after NCM");
1881    }
1882
1883    #[test]
1884    fn nve_multiple_codes_produces_correct_xml() {
1885        let item = sample_item().nve("AA0001").nve("BB0002").nve("CC0003");
1886        let data = sample_build_data();
1887        let result = build_det(&item, &data).expect("build_det should succeed");
1888
1889        assert!(result.xml.contains("<NVE>AA0001</NVE>"));
1890        assert!(result.xml.contains("<NVE>BB0002</NVE>"));
1891        assert!(result.xml.contains("<NVE>CC0003</NVE>"));
1892        // Verify order: AA0001 before BB0002 before CC0003
1893        let pos_a = result.xml.find("<NVE>AA0001</NVE>").expect("AA0001");
1894        let pos_b = result.xml.find("<NVE>BB0002</NVE>").expect("BB0002");
1895        let pos_c = result.xml.find("<NVE>CC0003</NVE>").expect("CC0003");
1896        assert!(pos_a < pos_b, "NVE codes must preserve insertion order");
1897        assert!(pos_b < pos_c, "NVE codes must preserve insertion order");
1898    }
1899
1900    #[test]
1901    fn nve_eight_codes_is_valid() {
1902        let item = sample_item()
1903            .nve("AA0001")
1904            .nve("AA0002")
1905            .nve("AA0003")
1906            .nve("AA0004")
1907            .nve("AA0005")
1908            .nve("AA0006")
1909            .nve("AA0007")
1910            .nve("AA0008");
1911        let data = sample_build_data();
1912        let result = build_det(&item, &data);
1913        assert!(result.is_ok(), "8 NVE codes should be valid");
1914        let xml = result.expect("valid").xml;
1915        assert_eq!(xml.matches("<NVE>").count(), 8);
1916    }
1917
1918    #[test]
1919    fn nve_nine_codes_returns_error() {
1920        let item = sample_item()
1921            .nve("AA0001")
1922            .nve("AA0002")
1923            .nve("AA0003")
1924            .nve("AA0004")
1925            .nve("AA0005")
1926            .nve("AA0006")
1927            .nve("AA0007")
1928            .nve("AA0008")
1929            .nve("AA0009");
1930        let data = sample_build_data();
1931        let result = build_det(&item, &data);
1932        assert!(result.is_err(), "9 NVE codes should be rejected");
1933        let err = result.unwrap_err();
1934        assert_eq!(
1935            err,
1936            FiscalError::InvalidTaxData("Item 1: NVE limited to 8 entries, got 9".to_string())
1937        );
1938    }
1939
1940    #[test]
1941    fn nve_empty_vec_produces_no_nve_tags() {
1942        let item = sample_item();
1943        let data = sample_build_data();
1944        let result = build_det(&item, &data).expect("build_det should succeed");
1945
1946        assert!(!result.xml.contains("<NVE>"));
1947    }
1948
1949    #[test]
1950    fn nve_appears_before_cest() {
1951        let item = sample_item().nve("AA0001").cest("1234567");
1952        let data = sample_build_data();
1953        let result = build_det(&item, &data).expect("build_det should succeed");
1954
1955        let nve_pos = result
1956            .xml
1957            .find("<NVE>AA0001</NVE>")
1958            .expect("<NVE> must exist");
1959        let cest_pos = result.xml.find("<CEST>").expect("<CEST> must exist");
1960        assert!(nve_pos < cest_pos, "NVE must come before CEST");
1961    }
1962
1963    // ── gCred (crédito presumido ICMS) ──────────────────────────────────────
1964
1965    #[test]
1966    fn gcred_single_with_value_produces_correct_xml() {
1967        let gc = GCredData::new("SP000001", Rate4(50000)).v_cred_presumido(Cents(1500));
1968        let item = sample_item().g_cred(vec![gc]);
1969        let data = sample_build_data();
1970        let result = build_det(&item, &data).expect("build_det should succeed");
1971
1972        assert!(result.xml.contains(
1973            "<gCred><cCredPresumido>SP000001</cCredPresumido>\
1974             <pCredPresumido>5.0000</pCredPresumido>\
1975             <vCredPresumido>15.00</vCredPresumido></gCred>"
1976        ));
1977    }
1978
1979    #[test]
1980    fn gcred_without_value_omits_v_cred_presumido() {
1981        let gc = GCredData::new("RJ000002", Rate4(120000));
1982        let item = sample_item().g_cred(vec![gc]);
1983        let data = sample_build_data();
1984        let result = build_det(&item, &data).expect("build_det should succeed");
1985
1986        assert!(result.xml.contains(
1987            "<gCred>\
1988                <cCredPresumido>RJ000002</cCredPresumido>\
1989                <pCredPresumido>12.0000</pCredPresumido>\
1990            </gCred>"
1991        ));
1992        assert!(!result.xml.contains("<vCredPresumido>"));
1993    }
1994
1995    #[test]
1996    fn gcred_multiple_entries_up_to_four() {
1997        let entries = vec![
1998            GCredData::new("SP000001", Rate4(10000)).v_cred_presumido(Cents(100)),
1999            GCredData::new("SP000002", Rate4(20000)).v_cred_presumido(Cents(200)),
2000            GCredData::new("SP000003", Rate4(30000)).v_cred_presumido(Cents(300)),
2001            GCredData::new("SP000004", Rate4(40000)).v_cred_presumido(Cents(400)),
2002        ];
2003        let item = sample_item().g_cred(entries);
2004        let data = sample_build_data();
2005        let result = build_det(&item, &data).expect("build_det should succeed");
2006
2007        assert!(
2008            result
2009                .xml
2010                .contains("<cCredPresumido>SP000001</cCredPresumido>")
2011        );
2012        assert!(
2013            result
2014                .xml
2015                .contains("<cCredPresumido>SP000002</cCredPresumido>")
2016        );
2017        assert!(
2018            result
2019                .xml
2020                .contains("<cCredPresumido>SP000003</cCredPresumido>")
2021        );
2022        assert!(
2023            result
2024                .xml
2025                .contains("<cCredPresumido>SP000004</cCredPresumido>")
2026        );
2027    }
2028
2029    #[test]
2030    fn gcred_truncates_at_four_entries() {
2031        let entries = vec![
2032            GCredData::new("SP000001", Rate4(10000)),
2033            GCredData::new("SP000002", Rate4(20000)),
2034            GCredData::new("SP000003", Rate4(30000)),
2035            GCredData::new("SP000004", Rate4(40000)),
2036            GCredData::new("SP000005", Rate4(50000)),
2037        ];
2038        let item = sample_item().g_cred(entries);
2039        let data = sample_build_data();
2040        let result = build_det(&item, &data).expect("build_det should succeed");
2041
2042        assert!(
2043            result
2044                .xml
2045                .contains("<cCredPresumido>SP000004</cCredPresumido>")
2046        );
2047        assert!(
2048            !result
2049                .xml
2050                .contains("<cCredPresumido>SP000005</cCredPresumido>")
2051        );
2052    }
2053
2054    #[test]
2055    fn gcred_positioned_after_cbenef_before_cfop() {
2056        let gc = GCredData::new("MG000001", Rate4(50000)).v_cred_presumido(Cents(1000));
2057        let item = sample_item().c_benef("SEM CBENEF").g_cred(vec![gc]);
2058        let data = sample_build_data();
2059        let result = build_det(&item, &data).expect("build_det should succeed");
2060
2061        let cbenef_pos = result.xml.find("<cBenef>").expect("cBenef should exist");
2062        let gcred_pos = result.xml.find("<gCred>").expect("gCred should exist");
2063        let cfop_pos = result.xml.find("<CFOP>").expect("CFOP should exist");
2064
2065        assert!(gcred_pos > cbenef_pos, "gCred must come after cBenef");
2066        assert!(gcred_pos < cfop_pos, "gCred must come before CFOP");
2067    }
2068
2069    #[test]
2070    fn gcred_empty_vec_produces_no_gcred_tags() {
2071        let item = sample_item();
2072        let data = sample_build_data();
2073        let result = build_det(&item, &data).expect("build_det should succeed");
2074
2075        assert!(!result.xml.contains("<gCred>"));
2076    }
2077
2078    // ── Helper: Normal tax regime build data ─────────────────────────────
2079
2080    fn normal_build_data() -> InvoiceBuildData {
2081        let mut data = sample_build_data();
2082        data.issuer.tax_regime = TaxRegime::Normal;
2083        data
2084    }
2085
2086    fn pl010_build_data() -> InvoiceBuildData {
2087        let mut data = sample_build_data();
2088        data.schema_version = crate::types::SchemaVersion::PL010;
2089        data
2090    }
2091
2092    // ── CSOSN variants (Simples Nacional) ────────────────────────────────
2093
2094    #[test]
2095    fn csosn_101_produces_correct_xml() {
2096        let item = InvoiceItemData::new(
2097            1,
2098            "001",
2099            "Produto",
2100            "27101259",
2101            "5102",
2102            "UN",
2103            1.0,
2104            Cents(1000),
2105            Cents(1000),
2106            "101",
2107            Rate(1800),
2108            Cents(180),
2109            "99",
2110            "99",
2111        )
2112        .icms_p_cred_sn(Rate(500))
2113        .icms_v_cred_icms_sn(Cents(50));
2114        let data = sample_build_data();
2115        let result = build_det(&item, &data).expect("build_det should succeed");
2116
2117        assert!(result.xml.contains("<ICMSSN101>"));
2118        assert!(result.xml.contains("<CSOSN>101</CSOSN>"));
2119        assert!(result.xml.contains("<pCredSN>"));
2120        assert!(result.xml.contains("<vCredICMSSN>"));
2121    }
2122
2123    #[test]
2124    fn csosn_101_missing_p_cred_sn_returns_error() {
2125        let item = InvoiceItemData::new(
2126            1,
2127            "001",
2128            "Produto",
2129            "27101259",
2130            "5102",
2131            "UN",
2132            1.0,
2133            Cents(1000),
2134            Cents(1000),
2135            "101",
2136            Rate(0),
2137            Cents(0),
2138            "99",
2139            "99",
2140        )
2141        .icms_v_cred_icms_sn(Cents(50));
2142        let data = sample_build_data();
2143        let result = build_det(&item, &data);
2144        assert!(result.is_err());
2145    }
2146
2147    #[test]
2148    fn csosn_101_missing_v_cred_icms_sn_returns_error() {
2149        let item = InvoiceItemData::new(
2150            1,
2151            "001",
2152            "Produto",
2153            "27101259",
2154            "5102",
2155            "UN",
2156            1.0,
2157            Cents(1000),
2158            Cents(1000),
2159            "101",
2160            Rate(0),
2161            Cents(0),
2162            "99",
2163            "99",
2164        )
2165        .icms_p_cred_sn(Rate(500));
2166        let data = sample_build_data();
2167        let result = build_det(&item, &data);
2168        assert!(result.is_err());
2169    }
2170
2171    #[test]
2172    fn csosn_empty_defaults_to_102() {
2173        // When icms_cst is empty for Simples, it should default to "102"
2174        let item = InvoiceItemData::new(
2175            1,
2176            "001",
2177            "Produto",
2178            "27101259",
2179            "5102",
2180            "UN",
2181            1.0,
2182            Cents(1000),
2183            Cents(1000),
2184            "",
2185            Rate(0),
2186            Cents(0),
2187            "99",
2188            "99",
2189        );
2190        let data = sample_build_data();
2191        let result = build_det(&item, &data).expect("build_det should succeed");
2192
2193        assert!(result.xml.contains("<ICMSSN102>"));
2194        assert!(result.xml.contains("<CSOSN>102</CSOSN>"));
2195    }
2196
2197    #[test]
2198    fn csosn_103_produces_correct_xml() {
2199        let item = InvoiceItemData::new(
2200            1,
2201            "001",
2202            "Produto",
2203            "27101259",
2204            "5102",
2205            "UN",
2206            1.0,
2207            Cents(1000),
2208            Cents(1000),
2209            "103",
2210            Rate(0),
2211            Cents(0),
2212            "99",
2213            "99",
2214        );
2215        let data = sample_build_data();
2216        let result = build_det(&item, &data).expect("build_det should succeed");
2217        // 102, 103, 300, 400 all share the ICMSSN102 tag name
2218        assert!(result.xml.contains("<ICMSSN102>"));
2219        assert!(result.xml.contains("<CSOSN>103</CSOSN>"));
2220    }
2221
2222    #[test]
2223    fn csosn_300_produces_correct_xml() {
2224        let item = InvoiceItemData::new(
2225            1,
2226            "001",
2227            "Produto",
2228            "27101259",
2229            "5102",
2230            "UN",
2231            1.0,
2232            Cents(1000),
2233            Cents(1000),
2234            "300",
2235            Rate(0),
2236            Cents(0),
2237            "99",
2238            "99",
2239        );
2240        let data = sample_build_data();
2241        let result = build_det(&item, &data).expect("build_det should succeed");
2242        assert!(result.xml.contains("<ICMSSN102>"));
2243        assert!(result.xml.contains("<CSOSN>300</CSOSN>"));
2244    }
2245
2246    #[test]
2247    fn csosn_400_produces_correct_xml() {
2248        let item = InvoiceItemData::new(
2249            1,
2250            "001",
2251            "Produto",
2252            "27101259",
2253            "5102",
2254            "UN",
2255            1.0,
2256            Cents(1000),
2257            Cents(1000),
2258            "400",
2259            Rate(0),
2260            Cents(0),
2261            "99",
2262            "99",
2263        );
2264        let data = sample_build_data();
2265        let result = build_det(&item, &data).expect("build_det should succeed");
2266        assert!(result.xml.contains("<ICMSSN102>"));
2267        assert!(result.xml.contains("<CSOSN>400</CSOSN>"));
2268    }
2269
2270    #[test]
2271    fn csosn_201_produces_correct_xml() {
2272        let item = InvoiceItemData::new(
2273            1,
2274            "001",
2275            "Produto",
2276            "27101259",
2277            "5102",
2278            "UN",
2279            1.0,
2280            Cents(1000),
2281            Cents(1000),
2282            "201",
2283            Rate(1800),
2284            Cents(180),
2285            "99",
2286            "99",
2287        )
2288        .icms_mod_bc_st(4)
2289        .icms_v_bc_st(Cents(1200))
2290        .icms_p_icms_st(Rate(1200))
2291        .icms_v_icms_st(Cents(144))
2292        .icms_p_mva_st(Rate(5000))
2293        .icms_red_bc_st(Rate(1000))
2294        .icms_v_bc_fcp_st(Cents(1200))
2295        .icms_p_fcp_st(Rate(200))
2296        .icms_v_fcp_st(Cents(24))
2297        .icms_p_cred_sn(Rate(500))
2298        .icms_v_cred_icms_sn(Cents(50));
2299        let data = sample_build_data();
2300        let result = build_det(&item, &data).expect("build_det should succeed");
2301
2302        assert!(result.xml.contains("<ICMSSN201>"));
2303        assert!(result.xml.contains("<CSOSN>201</CSOSN>"));
2304        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2305        assert!(result.xml.contains("<vBCST>12.00</vBCST>"));
2306        assert!(result.xml.contains("<pICMSST>"));
2307        assert!(result.xml.contains("<vICMSST>"));
2308        assert!(result.xml.contains("<pMVAST>"));
2309        assert!(result.xml.contains("<pRedBCST>"));
2310    }
2311
2312    #[test]
2313    fn csosn_202_produces_correct_xml() {
2314        let item = InvoiceItemData::new(
2315            1,
2316            "001",
2317            "Produto",
2318            "27101259",
2319            "5102",
2320            "UN",
2321            1.0,
2322            Cents(1000),
2323            Cents(1000),
2324            "202",
2325            Rate(0),
2326            Cents(0),
2327            "99",
2328            "99",
2329        )
2330        .icms_mod_bc_st(4)
2331        .icms_v_bc_st(Cents(1200))
2332        .icms_p_icms_st(Rate(1200))
2333        .icms_v_icms_st(Cents(144))
2334        .icms_p_mva_st(Rate(5000))
2335        .icms_red_bc_st(Rate(1000))
2336        .icms_v_bc_fcp_st(Cents(1200))
2337        .icms_p_fcp_st(Rate(200))
2338        .icms_v_fcp_st(Cents(24));
2339        let data = sample_build_data();
2340        let result = build_det(&item, &data).expect("build_det should succeed");
2341
2342        assert!(result.xml.contains("<ICMSSN202>"));
2343        assert!(result.xml.contains("<CSOSN>202</CSOSN>"));
2344        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2345    }
2346
2347    #[test]
2348    fn csosn_203_produces_correct_xml() {
2349        let item = InvoiceItemData::new(
2350            1,
2351            "001",
2352            "Produto",
2353            "27101259",
2354            "5102",
2355            "UN",
2356            1.0,
2357            Cents(1000),
2358            Cents(1000),
2359            "203",
2360            Rate(0),
2361            Cents(0),
2362            "99",
2363            "99",
2364        )
2365        .icms_mod_bc_st(4)
2366        .icms_v_bc_st(Cents(1200))
2367        .icms_p_icms_st(Rate(1200))
2368        .icms_v_icms_st(Cents(144))
2369        .icms_p_mva_st(Rate(5000))
2370        .icms_red_bc_st(Rate(1000))
2371        .icms_v_bc_fcp_st(Cents(1200))
2372        .icms_p_fcp_st(Rate(200))
2373        .icms_v_fcp_st(Cents(24));
2374        let data = sample_build_data();
2375        let result = build_det(&item, &data).expect("build_det should succeed");
2376
2377        // 202 and 203 share the ICMSSN202 tag name
2378        assert!(result.xml.contains("<ICMSSN202>"));
2379        assert!(result.xml.contains("<CSOSN>203</CSOSN>"));
2380        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2381    }
2382
2383    #[test]
2384    fn csosn_500_produces_correct_xml() {
2385        let item = InvoiceItemData::new(
2386            1,
2387            "001",
2388            "Produto",
2389            "27101259",
2390            "5102",
2391            "UN",
2392            1.0,
2393            Cents(1000),
2394            Cents(1000),
2395            "500",
2396            Rate(0),
2397            Cents(0),
2398            "99",
2399            "99",
2400        )
2401        .icms_v_icms_substituto(Cents(200));
2402        let data = sample_build_data();
2403        let result = build_det(&item, &data).expect("build_det should succeed");
2404
2405        assert!(result.xml.contains("<ICMSSN500>"));
2406        assert!(result.xml.contains("<CSOSN>500</CSOSN>"));
2407        assert!(result.xml.contains("<vICMSSubstituto>"));
2408    }
2409
2410    #[test]
2411    fn csosn_900_produces_correct_xml() {
2412        let item = InvoiceItemData::new(
2413            1,
2414            "001",
2415            "Produto",
2416            "27101259",
2417            "5102",
2418            "UN",
2419            1.0,
2420            Cents(1000),
2421            Cents(1000),
2422            "900",
2423            Rate(1800),
2424            Cents(180),
2425            "99",
2426            "99",
2427        )
2428        .icms_mod_bc(3)
2429        .icms_red_bc(Rate(1000))
2430        .icms_mod_bc_st(4)
2431        .icms_p_mva_st(Rate(5000))
2432        .icms_red_bc_st(Rate(1000))
2433        .icms_v_bc_st(Cents(1200))
2434        .icms_p_icms_st(Rate(1200))
2435        .icms_v_icms_st(Cents(144))
2436        .icms_v_bc_fcp_st(Cents(1200))
2437        .icms_p_fcp_st(Rate(200))
2438        .icms_v_fcp_st(Cents(24))
2439        .icms_p_cred_sn(Rate(500))
2440        .icms_v_cred_icms_sn(Cents(50));
2441        let data = sample_build_data();
2442        let result = build_det(&item, &data).expect("build_det should succeed");
2443
2444        assert!(result.xml.contains("<ICMSSN900>"));
2445        assert!(result.xml.contains("<CSOSN>900</CSOSN>"));
2446        assert!(result.xml.contains("<modBC>3</modBC>"));
2447    }
2448
2449    #[test]
2450    fn csosn_unsupported_returns_error() {
2451        let item = InvoiceItemData::new(
2452            1,
2453            "001",
2454            "Produto",
2455            "27101259",
2456            "5102",
2457            "UN",
2458            1.0,
2459            Cents(1000),
2460            Cents(1000),
2461            "999",
2462            Rate(0),
2463            Cents(0),
2464            "99",
2465            "99",
2466        );
2467        let data = sample_build_data();
2468        let result = build_det(&item, &data);
2469        assert!(result.is_err());
2470        let err = result.unwrap_err();
2471        assert!(
2472            matches!(err, FiscalError::UnsupportedIcmsCsosn(ref c) if c == "999"),
2473            "expected UnsupportedIcmsCsosn, got {:?}",
2474            err
2475        );
2476    }
2477
2478    // ── ICMS CST variants (Normal tax regime) ────────────────────────────
2479
2480    #[test]
2481    fn cst_10_produces_correct_xml() {
2482        let item = InvoiceItemData::new(
2483            1,
2484            "001",
2485            "Produto",
2486            "27101259",
2487            "5102",
2488            "UN",
2489            1.0,
2490            Cents(1000),
2491            Cents(1000),
2492            "10",
2493            Rate(1800),
2494            Cents(180),
2495            "99",
2496            "99",
2497        )
2498        .icms_mod_bc_st(4)
2499        .icms_v_bc_st(Cents(1200))
2500        .icms_p_icms_st(Rate(1200))
2501        .icms_v_icms_st(Cents(144))
2502        .icms_v_bc_fcp(Cents(1000))
2503        .icms_p_fcp(Rate(200))
2504        .icms_v_fcp(Cents(20))
2505        .icms_p_mva_st(Rate(5000))
2506        .icms_red_bc_st(Rate(1000))
2507        .icms_v_bc_fcp_st(Cents(1200))
2508        .icms_p_fcp_st(Rate(200))
2509        .icms_v_fcp_st(Cents(24));
2510        let data = normal_build_data();
2511        let result = build_det(&item, &data).expect("build_det should succeed");
2512
2513        assert!(result.xml.contains("<ICMS10>"));
2514        assert!(result.xml.contains("<CST>10</CST>"));
2515        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2516        assert!(result.xml.contains("<vBCST>12.00</vBCST>"));
2517        assert!(result.xml.contains("<pICMSST>"));
2518        assert!(result.xml.contains("<vICMSST>"));
2519    }
2520
2521    #[test]
2522    fn cst_20_produces_correct_xml() {
2523        let item = InvoiceItemData::new(
2524            1,
2525            "001",
2526            "Produto",
2527            "27101259",
2528            "5102",
2529            "UN",
2530            1.0,
2531            Cents(1000),
2532            Cents(1000),
2533            "20",
2534            Rate(1800),
2535            Cents(180),
2536            "99",
2537            "99",
2538        )
2539        .icms_mod_bc(3)
2540        .icms_red_bc(Rate(2000))
2541        .icms_v_bc_fcp(Cents(1000))
2542        .icms_p_fcp(Rate(200))
2543        .icms_v_fcp(Cents(20))
2544        .icms_v_icms_deson(Cents(50))
2545        .icms_mot_des_icms(9)
2546        .icms_ind_deduz_deson("1");
2547        let data = normal_build_data();
2548        let result = build_det(&item, &data).expect("build_det should succeed");
2549
2550        assert!(result.xml.contains("<ICMS20>"));
2551        assert!(result.xml.contains("<CST>20</CST>"));
2552        assert!(result.xml.contains("<pRedBC>"));
2553        assert!(result.xml.contains("<vICMSDeson>"));
2554        assert!(result.xml.contains("<motDesICMS>9</motDesICMS>"));
2555        assert!(result.xml.contains("<indDeduzDeson>1</indDeduzDeson>"));
2556        assert!(result.ind_deduz_deson);
2557    }
2558
2559    #[test]
2560    fn cst_30_produces_correct_xml() {
2561        let item = InvoiceItemData::new(
2562            1,
2563            "001",
2564            "Produto",
2565            "27101259",
2566            "5102",
2567            "UN",
2568            1.0,
2569            Cents(1000),
2570            Cents(1000),
2571            "30",
2572            Rate(0),
2573            Cents(0),
2574            "99",
2575            "99",
2576        )
2577        .icms_mod_bc_st(4)
2578        .icms_v_bc_st(Cents(1200))
2579        .icms_p_icms_st(Rate(1200))
2580        .icms_v_icms_st(Cents(144))
2581        .icms_p_mva_st(Rate(5000))
2582        .icms_red_bc_st(Rate(1000))
2583        .icms_v_bc_fcp_st(Cents(1200))
2584        .icms_p_fcp_st(Rate(200))
2585        .icms_v_fcp_st(Cents(24))
2586        .icms_v_icms_deson(Cents(50))
2587        .icms_mot_des_icms(9)
2588        .icms_ind_deduz_deson("1");
2589        let data = normal_build_data();
2590        let result = build_det(&item, &data).expect("build_det should succeed");
2591
2592        assert!(result.xml.contains("<ICMS30>"));
2593        assert!(result.xml.contains("<CST>30</CST>"));
2594        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2595        assert!(result.xml.contains("<vICMSDeson>"));
2596        assert!(result.xml.contains("<motDesICMS>9</motDesICMS>"));
2597    }
2598
2599    #[test]
2600    fn cst_40_produces_correct_xml() {
2601        let item = InvoiceItemData::new(
2602            1,
2603            "001",
2604            "Produto",
2605            "27101259",
2606            "5102",
2607            "UN",
2608            1.0,
2609            Cents(1000),
2610            Cents(1000),
2611            "40",
2612            Rate(0),
2613            Cents(0),
2614            "99",
2615            "99",
2616        )
2617        .icms_v_icms_deson(Cents(100))
2618        .icms_mot_des_icms(1)
2619        .icms_ind_deduz_deson("1");
2620        let data = normal_build_data();
2621        let result = build_det(&item, &data).expect("build_det should succeed");
2622
2623        assert!(result.xml.contains("<ICMS40>"));
2624        assert!(result.xml.contains("<CST>40</CST>"));
2625        assert!(result.xml.contains("<vICMSDeson>"));
2626        assert!(result.xml.contains("<motDesICMS>1</motDesICMS>"));
2627    }
2628
2629    #[test]
2630    fn cst_41_produces_correct_xml() {
2631        let item = InvoiceItemData::new(
2632            1,
2633            "001",
2634            "Produto",
2635            "27101259",
2636            "5102",
2637            "UN",
2638            1.0,
2639            Cents(1000),
2640            Cents(1000),
2641            "41",
2642            Rate(0),
2643            Cents(0),
2644            "99",
2645            "99",
2646        )
2647        .icms_v_icms_deson(Cents(100))
2648        .icms_mot_des_icms(1)
2649        .icms_ind_deduz_deson("1");
2650        let data = normal_build_data();
2651        let result = build_det(&item, &data).expect("build_det should succeed");
2652
2653        // 40, 41, 50 all share the ICMS40 tag name
2654        assert!(result.xml.contains("<ICMS40>"));
2655        assert!(result.xml.contains("<CST>41</CST>"));
2656        assert!(result.xml.contains("<vICMSDeson>"));
2657    }
2658
2659    #[test]
2660    fn cst_50_produces_correct_xml() {
2661        let item = InvoiceItemData::new(
2662            1,
2663            "001",
2664            "Produto",
2665            "27101259",
2666            "5102",
2667            "UN",
2668            1.0,
2669            Cents(1000),
2670            Cents(1000),
2671            "50",
2672            Rate(0),
2673            Cents(0),
2674            "99",
2675            "99",
2676        )
2677        .icms_v_icms_deson(Cents(100))
2678        .icms_mot_des_icms(1)
2679        .icms_ind_deduz_deson("1");
2680        let data = normal_build_data();
2681        let result = build_det(&item, &data).expect("build_det should succeed");
2682
2683        // 40, 41, 50 all share the ICMS40 tag name
2684        assert!(result.xml.contains("<ICMS40>"));
2685        assert!(result.xml.contains("<CST>50</CST>"));
2686        assert!(result.xml.contains("<vICMSDeson>"));
2687    }
2688
2689    #[test]
2690    fn cst_51_produces_correct_xml() {
2691        let item = InvoiceItemData::new(
2692            1,
2693            "001",
2694            "Produto",
2695            "27101259",
2696            "5102",
2697            "UN",
2698            1.0,
2699            Cents(1000),
2700            Cents(1000),
2701            "51",
2702            Rate(1800),
2703            Cents(180),
2704            "99",
2705            "99",
2706        )
2707        .icms_mod_bc(3)
2708        .icms_red_bc(Rate(1000))
2709        .icms_v_bc_fcp(Cents(1000))
2710        .icms_p_fcp(Rate(200))
2711        .icms_v_fcp(Cents(20));
2712        let data = normal_build_data();
2713        let result = build_det(&item, &data).expect("build_det should succeed");
2714
2715        assert!(result.xml.contains("<ICMS51>"));
2716        assert!(result.xml.contains("<CST>51</CST>"));
2717        assert!(result.xml.contains("<modBC>3</modBC>"));
2718        assert!(result.xml.contains("<pRedBC>"));
2719        assert!(result.xml.contains("<vICMS>"));
2720    }
2721
2722    #[test]
2723    fn cst_60_produces_correct_xml() {
2724        let item = InvoiceItemData::new(
2725            1,
2726            "001",
2727            "Produto",
2728            "27101259",
2729            "5102",
2730            "UN",
2731            1.0,
2732            Cents(1000),
2733            Cents(1000),
2734            "60",
2735            Rate(0),
2736            Cents(0),
2737            "99",
2738            "99",
2739        )
2740        .icms_v_icms_substituto(Cents(200));
2741        let data = normal_build_data();
2742        let result = build_det(&item, &data).expect("build_det should succeed");
2743
2744        assert!(result.xml.contains("<ICMS60>"));
2745        assert!(result.xml.contains("<CST>60</CST>"));
2746        assert!(result.xml.contains("<vICMSSubstituto>"));
2747    }
2748
2749    #[test]
2750    fn cst_70_produces_correct_xml() {
2751        let item = InvoiceItemData::new(
2752            1,
2753            "001",
2754            "Produto",
2755            "27101259",
2756            "5102",
2757            "UN",
2758            1.0,
2759            Cents(1000),
2760            Cents(1000),
2761            "70",
2762            Rate(1800),
2763            Cents(180),
2764            "99",
2765            "99",
2766        )
2767        .icms_mod_bc(3)
2768        .icms_red_bc(Rate(2000))
2769        .icms_mod_bc_st(4)
2770        .icms_v_bc_st(Cents(1200))
2771        .icms_p_icms_st(Rate(1200))
2772        .icms_v_icms_st(Cents(144))
2773        .icms_v_bc_fcp(Cents(1000))
2774        .icms_p_fcp(Rate(200))
2775        .icms_v_fcp(Cents(20))
2776        .icms_p_mva_st(Rate(5000))
2777        .icms_red_bc_st(Rate(1000))
2778        .icms_v_bc_fcp_st(Cents(1200))
2779        .icms_p_fcp_st(Rate(200))
2780        .icms_v_fcp_st(Cents(24))
2781        .icms_v_icms_deson(Cents(50))
2782        .icms_mot_des_icms(9)
2783        .icms_ind_deduz_deson("1");
2784        let data = normal_build_data();
2785        let result = build_det(&item, &data).expect("build_det should succeed");
2786
2787        assert!(result.xml.contains("<ICMS70>"));
2788        assert!(result.xml.contains("<CST>70</CST>"));
2789        assert!(result.xml.contains("<pRedBC>"));
2790        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2791        assert!(result.xml.contains("<vICMSDeson>"));
2792    }
2793
2794    #[test]
2795    fn cst_90_produces_correct_xml() {
2796        let item = InvoiceItemData::new(
2797            1,
2798            "001",
2799            "Produto",
2800            "27101259",
2801            "5102",
2802            "UN",
2803            1.0,
2804            Cents(1000),
2805            Cents(1000),
2806            "90",
2807            Rate(1800),
2808            Cents(180),
2809            "99",
2810            "99",
2811        )
2812        .icms_mod_bc(3)
2813        .icms_red_bc(Rate(1000))
2814        .icms_v_bc_fcp(Cents(1000))
2815        .icms_p_fcp(Rate(200))
2816        .icms_v_fcp(Cents(20))
2817        .icms_mod_bc_st(4)
2818        .icms_p_mva_st(Rate(5000))
2819        .icms_red_bc_st(Rate(1000))
2820        .icms_v_bc_st(Cents(1200))
2821        .icms_p_icms_st(Rate(1200))
2822        .icms_v_icms_st(Cents(144))
2823        .icms_v_bc_fcp_st(Cents(1200))
2824        .icms_p_fcp_st(Rate(200))
2825        .icms_v_fcp_st(Cents(24))
2826        .icms_v_icms_deson(Cents(50))
2827        .icms_mot_des_icms(9)
2828        .icms_ind_deduz_deson("1");
2829        let data = normal_build_data();
2830        let result = build_det(&item, &data).expect("build_det should succeed");
2831
2832        assert!(result.xml.contains("<ICMS90>"));
2833        assert!(result.xml.contains("<CST>90</CST>"));
2834        assert!(result.xml.contains("<modBC>3</modBC>"));
2835        assert!(result.xml.contains("<modBCST>4</modBCST>"));
2836        assert!(result.xml.contains("<vICMSDeson>"));
2837    }
2838
2839    #[test]
2840    fn cst_unsupported_returns_error() {
2841        let item = InvoiceItemData::new(
2842            1,
2843            "001",
2844            "Produto",
2845            "27101259",
2846            "5102",
2847            "UN",
2848            1.0,
2849            Cents(1000),
2850            Cents(1000),
2851            "99",
2852            Rate(0),
2853            Cents(0),
2854            "99",
2855            "99",
2856        );
2857        let data = normal_build_data();
2858        let result = build_det(&item, &data);
2859        assert!(result.is_err());
2860        let err = result.unwrap_err();
2861        assert!(
2862            matches!(err, FiscalError::UnsupportedIcmsCst(ref c) if c == "99"),
2863            "expected UnsupportedIcmsCst, got {:?}",
2864            err
2865        );
2866    }
2867
2868    // ── IPI (optional) ───────────────────────────────────────────────────
2869
2870    #[test]
2871    fn ipi_produces_correct_xml() {
2872        let item = sample_item()
2873            .ipi_cst("50")
2874            .ipi_c_enq("999")
2875            .ipi_v_bc(Cents(10000))
2876            .ipi_p_ipi(Rate(500))
2877            .ipi_v_ipi(Cents(500));
2878        let data = sample_build_data();
2879        let result = build_det(&item, &data).expect("build_det should succeed");
2880
2881        assert!(result.xml.contains("<IPI>"));
2882        assert!(result.xml.contains("<CST>50</CST>"));
2883        assert!(result.xml.contains("<cEnq>999</cEnq>"));
2884        assert!(result.xml.contains("<vIPI>5.00</vIPI>"));
2885        assert_eq!(result.v_ipi, 500);
2886    }
2887
2888    #[test]
2889    fn ipi_default_c_enq_when_missing() {
2890        let item = sample_item()
2891            .ipi_cst("50")
2892            .ipi_v_bc(Cents(10000))
2893            .ipi_p_ipi(Rate(500))
2894            .ipi_v_ipi(Cents(500));
2895        let data = sample_build_data();
2896        let result = build_det(&item, &data).expect("build_det should succeed");
2897
2898        assert!(result.xml.contains("<cEnq>999</cEnq>"));
2899    }
2900
2901    #[test]
2902    fn ipi_with_quantity_based() {
2903        let item = sample_item()
2904            .ipi_cst("50")
2905            .ipi_q_unid(100)
2906            .ipi_v_unid(50)
2907            .ipi_v_ipi(Cents(5000));
2908        let data = sample_build_data();
2909        let result = build_det(&item, &data).expect("build_det should succeed");
2910
2911        assert!(result.xml.contains("<IPI>"));
2912        assert_eq!(result.v_ipi, 5000);
2913    }
2914
2915    #[test]
2916    fn no_ipi_when_cst_absent() {
2917        let item = sample_item();
2918        let data = sample_build_data();
2919        let result = build_det(&item, &data).expect("build_det should succeed");
2920
2921        assert!(!result.xml.contains("<IPI>"));
2922        assert_eq!(result.v_ipi, 0);
2923    }
2924
2925    // ── II (Import Tax) ──────────────────────────────────────────────────
2926
2927    #[test]
2928    fn ii_produces_correct_xml() {
2929        let item = sample_item()
2930            .ii_v_bc(Cents(50000))
2931            .ii_v_desp_adu(Cents(5000))
2932            .ii_v_ii(Cents(10000))
2933            .ii_v_iof(Cents(2000));
2934        let data = sample_build_data();
2935        let result = build_det(&item, &data).expect("build_det should succeed");
2936
2937        assert!(result.xml.contains("<II>"));
2938        assert!(result.xml.contains("<vBC>500.00</vBC>"));
2939        assert!(result.xml.contains("<vDespAdu>50.00</vDespAdu>"));
2940        assert!(result.xml.contains("<vII>100.00</vII>"));
2941        assert!(result.xml.contains("<vIOF>20.00</vIOF>"));
2942        assert_eq!(result.v_ii, 10000);
2943    }
2944
2945    #[test]
2946    fn no_ii_when_absent() {
2947        let item = sample_item();
2948        let data = sample_build_data();
2949        let result = build_det(&item, &data).expect("build_det should succeed");
2950
2951        assert!(!result.xml.contains("<II>"));
2952        assert_eq!(result.v_ii, 0);
2953    }
2954
2955    // ── PIS-ST / COFINS-ST ──────────────────────────────────────────────
2956
2957    #[test]
2958    fn pis_st_replaces_pis_and_accumulates_when_ind_soma_1() {
2959        use crate::tax_pis_cofins_ipi::PisStData;
2960        let pis_st = PisStData::new(Cents(500))
2961            .v_bc(Cents(10000))
2962            .p_pis(Rate4(16500))
2963            .ind_soma_pis_st(1);
2964        let item = sample_item().pis_st(pis_st);
2965        let data = sample_build_data();
2966        let result = build_det(&item, &data).expect("build_det should succeed");
2967
2968        assert!(result.xml.contains("<PISST>"));
2969        assert!(!result.xml.contains("<PISAliq>"));
2970        assert_eq!(result.v_pis_st, 500);
2971    }
2972
2973    #[test]
2974    fn pis_st_does_not_accumulate_when_ind_soma_0() {
2975        use crate::tax_pis_cofins_ipi::PisStData;
2976        let pis_st = PisStData::new(Cents(500))
2977            .v_bc(Cents(10000))
2978            .p_pis(Rate4(16500))
2979            .ind_soma_pis_st(0);
2980        let item = sample_item().pis_st(pis_st);
2981        let data = sample_build_data();
2982        let result = build_det(&item, &data).expect("build_det should succeed");
2983
2984        assert!(result.xml.contains("<PISST>"));
2985        assert_eq!(result.v_pis_st, 0);
2986    }
2987
2988    #[test]
2989    fn cofins_st_replaces_cofins_and_accumulates_when_ind_soma_1() {
2990        use crate::tax_pis_cofins_ipi::CofinsStData;
2991        let cofins_st = CofinsStData::new(Cents(750))
2992            .v_bc(Cents(10000))
2993            .p_cofins(Rate4(76000))
2994            .ind_soma_cofins_st(1);
2995        let item = sample_item().cofins_st(cofins_st);
2996        let data = sample_build_data();
2997        let result = build_det(&item, &data).expect("build_det should succeed");
2998
2999        assert!(result.xml.contains("<COFINSST>"));
3000        assert!(!result.xml.contains("<COFINSAliq>"));
3001        assert_eq!(result.v_cofins_st, 750);
3002    }
3003
3004    #[test]
3005    fn cofins_st_does_not_accumulate_when_ind_soma_0() {
3006        use crate::tax_pis_cofins_ipi::CofinsStData;
3007        let cofins_st = CofinsStData::new(Cents(750))
3008            .v_bc(Cents(10000))
3009            .p_cofins(Rate4(76000))
3010            .ind_soma_cofins_st(0);
3011        let item = sample_item().cofins_st(cofins_st);
3012        let data = sample_build_data();
3013        let result = build_det(&item, &data).expect("build_det should succeed");
3014
3015        assert!(result.xml.contains("<COFINSST>"));
3016        assert_eq!(result.v_cofins_st, 0);
3017    }
3018
3019    // ── IS / IBS-CBS (PL010 schema) ─────────────────────────────────────
3020
3021    #[test]
3022    fn is_data_emitted_with_pl010_schema() {
3023        use crate::tax_is::IsData;
3024        let is = IsData::new("00", "001", "5.00");
3025        let item = sample_item().is_data(is);
3026        let data = pl010_build_data();
3027        let result = build_det(&item, &data).expect("build_det should succeed");
3028
3029        assert!(result.xml.contains("<IS>"));
3030    }
3031
3032    #[test]
3033    fn is_data_not_emitted_with_pl009_schema() {
3034        use crate::tax_is::IsData;
3035        let is = IsData::new("00", "001", "5.00");
3036        let item = sample_item().is_data(is);
3037        let data = sample_build_data(); // PL009
3038        let result = build_det(&item, &data).expect("build_det should succeed");
3039
3040        assert!(!result.xml.contains("<IS>"));
3041    }
3042
3043    #[test]
3044    fn ibs_cbs_data_emitted_with_pl010_schema() {
3045        use crate::tax_ibs_cbs::IbsCbsData;
3046        let ibs_cbs = IbsCbsData::new("00", "001");
3047        let item = sample_item().ibs_cbs(ibs_cbs);
3048        let data = pl010_build_data();
3049        let result = build_det(&item, &data).expect("build_det should succeed");
3050
3051        assert!(result.xml.contains("<IBSCBS>"));
3052    }
3053
3054    // ── CEST with indEscala and CNPJFab ─────────────────────────────────
3055
3056    #[test]
3057    fn cest_with_ind_escala_and_cnpj_fab() {
3058        let item = sample_item()
3059            .cest("1234567")
3060            .cest_ind_escala("S")
3061            .cest_cnpj_fab("12345678000199");
3062        let data = sample_build_data();
3063        let result = build_det(&item, &data).expect("build_det should succeed");
3064
3065        assert!(result.xml.contains("<CEST>1234567</CEST>"));
3066        assert!(result.xml.contains("<indEscala>S</indEscala>"));
3067        assert!(result.xml.contains("<CNPJFab>12345678000199</CNPJFab>"));
3068    }
3069
3070    // ── EXTIPI ──────────────────────────────────────────────────────────
3071
3072    #[test]
3073    fn extipi_produces_correct_xml() {
3074        let item = sample_item().extipi("01");
3075        let data = sample_build_data();
3076        let result = build_det(&item, &data).expect("build_det should succeed");
3077
3078        assert!(result.xml.contains("<EXTIPI>01</EXTIPI>"));
3079        // EXTIPI must appear after NCM and before CFOP
3080        let ncm_pos = result.xml.find("<NCM>").unwrap();
3081        let extipi_pos = result.xml.find("<EXTIPI>").unwrap();
3082        let cfop_pos = result.xml.find("<CFOP>").unwrap();
3083        assert!(extipi_pos > ncm_pos);
3084        assert!(extipi_pos < cfop_pos);
3085    }
3086
3087    // ── nItemPed, nFCI ──────────────────────────────────────────────────
3088
3089    #[test]
3090    fn n_item_ped_produces_correct_xml() {
3091        let item = sample_item().n_item_ped("5");
3092        let data = sample_build_data();
3093        let result = build_det(&item, &data).expect("build_det should succeed");
3094
3095        assert!(result.xml.contains("<nItemPed>5</nItemPed>"));
3096    }
3097
3098    #[test]
3099    fn n_fci_produces_correct_xml() {
3100        let item = sample_item().n_fci("B01F70AF-10BF-4B1F-848C-65FF57F616FE");
3101        let data = sample_build_data();
3102        let result = build_det(&item, &data).expect("build_det should succeed");
3103
3104        assert!(
3105            result
3106                .xml
3107                .contains("<nFCI>B01F70AF-10BF-4B1F-848C-65FF57F616FE</nFCI>")
3108        );
3109    }
3110
3111    // ── Veículo (veicProd) ──────────────────────────────────────────────
3112
3113    #[test]
3114    fn veic_prod_produces_correct_xml() {
3115        let veic = VeicProdData::new(
3116            "1",
3117            "9BWZZZ377VT004251",
3118            "1",
3119            "PRATA",
3120            "100",
3121            "1600",
3122            "1050",
3123            "1250",
3124            "ABC123",
3125            "1",
3126            "MOT123",
3127            "1500",
3128            "2600",
3129            "2025",
3130            "2025",
3131            "M",
3132            "06",
3133            "1",
3134            "R",
3135            "1",
3136            "MOD001",
3137            "02",
3138            "5",
3139            "0",
3140        );
3141        let item = sample_item().veic_prod(veic);
3142        let data = sample_build_data();
3143        let result = build_det(&item, &data).expect("build_det should succeed");
3144
3145        assert!(result.xml.contains("<veicProd>"));
3146        assert!(result.xml.contains("<tpOp>1</tpOp>"));
3147        assert!(result.xml.contains("<chassi>9BWZZZ377VT004251</chassi>"));
3148        assert!(result.xml.contains("<cCor>1</cCor>"));
3149        assert!(result.xml.contains("<xCor>PRATA</xCor>"));
3150        assert!(result.xml.contains("<pot>100</pot>"));
3151        assert!(result.xml.contains("<cilin>1600</cilin>"));
3152        assert!(result.xml.contains("<pesoL>1050</pesoL>"));
3153        assert!(result.xml.contains("<pesoB>1250</pesoB>"));
3154        assert!(result.xml.contains("<nSerie>ABC123</nSerie>"));
3155        assert!(result.xml.contains("<tpComb>1</tpComb>"));
3156        assert!(result.xml.contains("<nMotor>MOT123</nMotor>"));
3157        assert!(result.xml.contains("<CMT>1500</CMT>"));
3158        assert!(result.xml.contains("<dist>2600</dist>"));
3159        assert!(result.xml.contains("<anoMod>2025</anoMod>"));
3160        assert!(result.xml.contains("<anoFab>2025</anoFab>"));
3161        assert!(result.xml.contains("<tpPint>M</tpPint>"));
3162        assert!(result.xml.contains("<tpVeic>06</tpVeic>"));
3163        assert!(result.xml.contains("<espVeic>1</espVeic>"));
3164        assert!(result.xml.contains("<VIN>R</VIN>"));
3165        assert!(result.xml.contains("<condVeic>1</condVeic>"));
3166        assert!(result.xml.contains("<cMod>MOD001</cMod>"));
3167        assert!(result.xml.contains("<cCorDENATRAN>02</cCorDENATRAN>"));
3168        assert!(result.xml.contains("<lota>5</lota>"));
3169        assert!(result.xml.contains("<tpRest>0</tpRest>"));
3170        assert!(result.xml.contains("</veicProd>"));
3171    }
3172
3173    // ── Medicamento (med) ───────────────────────────────────────────────
3174
3175    #[test]
3176    fn med_with_anvisa_code() {
3177        let med = MedData::new(Cents(5000)).c_prod_anvisa("1234567890123");
3178        let item = sample_item().med(med);
3179        let data = sample_build_data();
3180        let result = build_det(&item, &data).expect("build_det should succeed");
3181
3182        assert!(result.xml.contains("<med>"));
3183        assert!(
3184            result
3185                .xml
3186                .contains("<cProdANVISA>1234567890123</cProdANVISA>")
3187        );
3188        assert!(result.xml.contains("<vPMC>50.00</vPMC>"));
3189        assert!(result.xml.contains("</med>"));
3190    }
3191
3192    #[test]
3193    fn med_with_exemption_reason() {
3194        let med = MedData::new(Cents(3000)).x_motivo_isencao("Medicamento isento de registro");
3195        let item = sample_item().med(med);
3196        let data = sample_build_data();
3197        let result = build_det(&item, &data).expect("build_det should succeed");
3198
3199        assert!(result.xml.contains("<med>"));
3200        assert!(
3201            result
3202                .xml
3203                .contains("<xMotivoIsencao>Medicamento isento de registro</xMotivoIsencao>")
3204        );
3205        assert!(result.xml.contains("<vPMC>30.00</vPMC>"));
3206        assert!(!result.xml.contains("<cProdANVISA>"));
3207    }
3208
3209    // ── Arma (weapon) ───────────────────────────────────────────────────
3210
3211    #[test]
3212    fn arma_single_produces_correct_xml() {
3213        let arma = ArmaData::new("0", "SN12345", "CN6789", "Pistola Taurus");
3214        let item = sample_item().arma(vec![arma]);
3215        let data = sample_build_data();
3216        let result = build_det(&item, &data).expect("build_det should succeed");
3217
3218        assert!(result.xml.contains("<arma>"));
3219        assert!(result.xml.contains("<tpArma>0</tpArma>"));
3220        assert!(result.xml.contains("<nSerie>SN12345</nSerie>"));
3221        assert!(result.xml.contains("<nCano>CN6789</nCano>"));
3222        assert!(result.xml.contains("<descr>Pistola Taurus</descr>"));
3223        assert!(result.xml.contains("</arma>"));
3224    }
3225
3226    #[test]
3227    fn arma_multiple_produces_multiple_elements() {
3228        let a1 = ArmaData::new("0", "SN001", "CN001", "Arma 1");
3229        let a2 = ArmaData::new("1", "SN002", "CN002", "Arma 2");
3230        let item = sample_item().arma(vec![a1, a2]);
3231        let data = sample_build_data();
3232        let result = build_det(&item, &data).expect("build_det should succeed");
3233
3234        assert_eq!(result.xml.matches("<arma>").count(), 2);
3235        assert!(result.xml.contains("<nSerie>SN001</nSerie>"));
3236        assert!(result.xml.contains("<nSerie>SN002</nSerie>"));
3237    }
3238
3239    // ── nRECOPI ─────────────────────────────────────────────────────────
3240
3241    #[test]
3242    fn n_recopi_produces_correct_xml() {
3243        let item = sample_item().n_recopi("20250000001234567890");
3244        let data = sample_build_data();
3245        let result = build_det(&item, &data).expect("build_det should succeed");
3246
3247        assert!(
3248            result
3249                .xml
3250                .contains("<nRECOPI>20250000001234567890</nRECOPI>")
3251        );
3252    }
3253
3254    #[test]
3255    fn n_recopi_empty_not_emitted() {
3256        let item = sample_item().n_recopi("");
3257        let data = sample_build_data();
3258        let result = build_det(&item, &data).expect("build_det should succeed");
3259
3260        assert!(!result.xml.contains("<nRECOPI>"));
3261    }
3262
3263    // ── Rastro (batch tracking) ─────────────────────────────────────────
3264
3265    #[test]
3266    fn rastro_single_produces_correct_xml() {
3267        let r = RastroData::new("LOTE001", 10.5, "2025-01-01", "2026-01-01");
3268        let item = sample_item().rastro(vec![r]);
3269        let data = sample_build_data();
3270        let result = build_det(&item, &data).expect("build_det should succeed");
3271
3272        assert!(result.xml.contains("<rastro>"));
3273        assert!(result.xml.contains("<nLote>LOTE001</nLote>"));
3274        assert!(result.xml.contains("<qLote>10.500</qLote>"));
3275        assert!(result.xml.contains("<dFab>2025-01-01</dFab>"));
3276        assert!(result.xml.contains("<dVal>2026-01-01</dVal>"));
3277        assert!(result.xml.contains("</rastro>"));
3278    }
3279
3280    #[test]
3281    fn rastro_with_c_agreg() {
3282        let r = RastroData::new("LOTE002", 5.0, "2025-06-01", "2026-06-01").c_agreg("AGREG001");
3283        let item = sample_item().rastro(vec![r]);
3284        let data = sample_build_data();
3285        let result = build_det(&item, &data).expect("build_det should succeed");
3286
3287        assert!(result.xml.contains("<cAgreg>AGREG001</cAgreg>"));
3288    }
3289
3290    // ── obsItem with obsFisco ───────────────────────────────────────────
3291
3292    #[test]
3293    fn obs_item_with_obs_cont_only() {
3294        use crate::types::{ObsField, ObsItemData};
3295        let obs = ObsItemData::new().obs_cont(ObsField::new("campo1", "texto1"));
3296        let item = sample_item().obs_item(obs);
3297        let data = sample_build_data();
3298        let result = build_det(&item, &data).expect("build_det should succeed");
3299
3300        assert!(result.xml.contains("<obsItem>"));
3301        assert!(result.xml.contains("<obsCont xCampo=\"campo1\">"));
3302        assert!(result.xml.contains("<xTexto>texto1</xTexto>"));
3303        assert!(!result.xml.contains("<obsFisco"));
3304    }
3305
3306    #[test]
3307    fn obs_item_with_obs_fisco() {
3308        use crate::types::{ObsField, ObsItemData};
3309        let obs = ObsItemData::new().obs_fisco(ObsField::new("campo_fisco", "texto_fisco"));
3310        let item = sample_item().obs_item(obs);
3311        let data = sample_build_data();
3312        let result = build_det(&item, &data).expect("build_det should succeed");
3313
3314        assert!(result.xml.contains("<obsItem>"));
3315        assert!(result.xml.contains("<obsFisco xCampo=\"campo_fisco\">"));
3316        assert!(result.xml.contains("<xTexto>texto_fisco</xTexto>"));
3317    }
3318
3319    #[test]
3320    fn obs_item_with_both_obs_cont_and_obs_fisco() {
3321        use crate::types::{ObsField, ObsItemData};
3322        let obs = ObsItemData::new()
3323            .obs_cont(ObsField::new("campo_cont", "texto_cont"))
3324            .obs_fisco(ObsField::new("campo_fisco", "texto_fisco"));
3325        let item = sample_item().obs_item(obs);
3326        let data = sample_build_data();
3327        let result = build_det(&item, &data).expect("build_det should succeed");
3328
3329        assert!(result.xml.contains("<obsCont xCampo=\"campo_cont\">"));
3330        assert!(result.xml.contains("<obsFisco xCampo=\"campo_fisco\">"));
3331    }
3332
3333    // ── DFeReferenciado ─────────────────────────────────────────────────
3334
3335    #[test]
3336    fn dfe_referenciado_without_n_item() {
3337        use crate::types::DFeReferenciadoData;
3338        let dfe = DFeReferenciadoData::new("12345678901234567890123456789012345678901234");
3339        let item = sample_item().dfe_referenciado(dfe);
3340        let data = sample_build_data();
3341        let result = build_det(&item, &data).expect("build_det should succeed");
3342
3343        assert!(result.xml.contains("<DFeReferenciado>"));
3344        assert!(
3345            result.xml.contains(
3346                "<chaveAcesso>12345678901234567890123456789012345678901234</chaveAcesso>"
3347            )
3348        );
3349        assert!(!result.xml.contains("<nItem>"));
3350    }
3351
3352    #[test]
3353    fn dfe_referenciado_with_n_item() {
3354        use crate::types::DFeReferenciadoData;
3355        let dfe =
3356            DFeReferenciadoData::new("12345678901234567890123456789012345678901234").n_item("3");
3357        let item = sample_item().dfe_referenciado(dfe);
3358        let data = sample_build_data();
3359        let result = build_det(&item, &data).expect("build_det should succeed");
3360
3361        assert!(result.xml.contains("<DFeReferenciado>"));
3362        assert!(result.xml.contains("<nItem>3</nItem>"));
3363    }
3364
3365    // ── Homologation xProd substitution (NFC-e) ─────────────────────────
3366
3367    #[test]
3368    fn nfce_homologation_substitutes_xprod_for_item_1() {
3369        let item = sample_item();
3370        let mut data = sample_build_data();
3371        data.model = InvoiceModel::Nfce;
3372        data.environment = SefazEnvironment::Homologation;
3373        let result = build_det(&item, &data).expect("build_det should succeed");
3374
3375        assert!(result.xml.contains(HOMOLOGATION_XPROD));
3376    }
3377
3378    #[test]
3379    fn nfce_homologation_does_not_substitute_for_item_2() {
3380        let mut item = sample_item();
3381        item.item_number = 2;
3382        let mut data = sample_build_data();
3383        data.model = InvoiceModel::Nfce;
3384        data.environment = SefazEnvironment::Homologation;
3385        let result = build_det(&item, &data).expect("build_det should succeed");
3386
3387        assert!(!result.xml.contains(HOMOLOGATION_XPROD));
3388        assert!(result.xml.contains("<xProd>Gasolina Comum</xProd>"));
3389    }
3390
3391    // ── v_frete, v_seg, v_desc, v_outro ─────────────────────────────────
3392
3393    #[test]
3394    fn optional_value_fields_in_det_result() {
3395        let item = sample_item()
3396            .v_frete(Cents(1000))
3397            .v_seg(Cents(500))
3398            .v_desc(Cents(200))
3399            .v_outro(Cents(300));
3400        let data = sample_build_data();
3401        let result = build_det(&item, &data).expect("build_det should succeed");
3402
3403        assert!(result.xml.contains("<vFrete>10.00</vFrete>"));
3404        assert!(result.xml.contains("<vSeg>5.00</vSeg>"));
3405        assert!(result.xml.contains("<vDesc>2.00</vDesc>"));
3406        assert!(result.xml.contains("<vOutro>3.00</vOutro>"));
3407        assert_eq!(result.v_frete, 1000);
3408        assert_eq!(result.v_seg, 500);
3409        assert_eq!(result.v_desc, 200);
3410        assert_eq!(result.v_outro, 300);
3411    }
3412
3413    // ── ind_tot override ────────────────────────────────────────────────
3414
3415    #[test]
3416    fn ind_tot_zero_excludes_from_total() {
3417        let item = sample_item().ind_tot(0);
3418        let data = sample_build_data();
3419        let result = build_det(&item, &data).expect("build_det should succeed");
3420
3421        assert!(result.xml.contains("<indTot>0</indTot>"));
3422        assert_eq!(result.ind_tot, 0);
3423    }
3424
3425    // ── v_tot_trib ──────────────────────────────────────────────────────
3426
3427    #[test]
3428    fn v_tot_trib_propagated_to_result() {
3429        let item = sample_item().v_tot_trib(Cents(1234));
3430        let data = sample_build_data();
3431        let result = build_det(&item, &data).expect("build_det should succeed");
3432
3433        assert_eq!(result.v_tot_trib, 1234);
3434    }
3435
3436    // ── xPed ────────────────────────────────────────────────────────────
3437
3438    #[test]
3439    fn x_ped_produces_correct_xml() {
3440        let item = sample_item().x_ped("PEDIDO-001");
3441        let data = sample_build_data();
3442        let result = build_det(&item, &data).expect("build_det should succeed");
3443
3444        assert!(result.xml.contains("<xPed>PEDIDO-001</xPed>"));
3445    }
3446
3447    // ── infAdProd ───────────────────────────────────────────────────────
3448
3449    #[test]
3450    fn inf_ad_prod_produces_correct_xml() {
3451        let item = sample_item().inf_ad_prod("informacao adicional do produto");
3452        let data = sample_build_data();
3453        let result = build_det(&item, &data).expect("build_det should succeed");
3454
3455        assert!(
3456            result
3457                .xml
3458                .contains("<infAdProd>informacao adicional do produto</infAdProd>")
3459        );
3460    }
3461
3462    // ── ind_deduz_deson ─────────────────────────────────────────────────
3463
3464    #[test]
3465    fn ind_deduz_deson_true_when_set_to_1() {
3466        let item = InvoiceItemData::new(
3467            1,
3468            "001",
3469            "Produto",
3470            "27101259",
3471            "5102",
3472            "UN",
3473            1.0,
3474            Cents(1000),
3475            Cents(1000),
3476            "40",
3477            Rate(0),
3478            Cents(0),
3479            "99",
3480            "99",
3481        )
3482        .icms_v_icms_deson(Cents(100))
3483        .icms_mot_des_icms(1)
3484        .icms_ind_deduz_deson("1");
3485        let data = normal_build_data();
3486        let result = build_det(&item, &data).expect("build_det should succeed");
3487        assert!(result.ind_deduz_deson);
3488    }
3489
3490    #[test]
3491    fn ind_deduz_deson_false_when_not_set() {
3492        let item = sample_item();
3493        let data = sample_build_data();
3494        let result = build_det(&item, &data).expect("build_det should succeed");
3495        assert!(!result.ind_deduz_deson);
3496    }
3497
3498    // ── orig override ───────────────────────────────────────────────────
3499
3500    #[test]
3501    fn custom_orig_used_in_icms() {
3502        let item = InvoiceItemData::new(
3503            1,
3504            "001",
3505            "Produto",
3506            "27101259",
3507            "5102",
3508            "UN",
3509            1.0,
3510            Cents(1000),
3511            Cents(1000),
3512            "00",
3513            Rate(1800),
3514            Cents(180),
3515            "99",
3516            "99",
3517        )
3518        .orig("1");
3519        let data = normal_build_data();
3520        let result = build_det(&item, &data).expect("build_det should succeed");
3521
3522        assert!(result.xml.contains("<orig>1</orig>"));
3523    }
3524}