Skip to main content

fiscal_core/xml_builder/
total.rs

1//! Build the `<total>` group of the NF-e XML.
2
3use crate::format_utils::format_cents;
4use crate::newtypes::Cents;
5use crate::tax_ibs_cbs::{self, IbsCbsTotData, IsTotData};
6use crate::tax_icms::IcmsTotals;
7use crate::types::{CalculationMethod, IssqnTotData, RetTribData, SchemaVersion};
8use crate::xml_utils::{TagContent, tag};
9
10/// Accumulated non-ICMS totals for the invoice total calculation.
11#[derive(Debug, Clone)]
12pub struct OtherTotals {
13    /// Total IPI value in cents.
14    pub v_ipi: i64,
15    /// Total PIS value in cents.
16    pub v_pis: i64,
17    /// Total COFINS value in cents.
18    pub v_cofins: i64,
19    /// Total II (import tax) value in cents.
20    pub v_ii: i64,
21    /// Total freight value in cents (accumulated from items).
22    pub v_frete: i64,
23    /// Total insurance value in cents (accumulated from items).
24    pub v_seg: i64,
25    /// Total discount value in cents (accumulated from items).
26    pub v_desc: i64,
27    /// Total other expenses value in cents (accumulated from items).
28    pub v_outro: i64,
29    /// Total approximate tax value in cents (vTotTrib, optional).
30    pub v_tot_trib: i64,
31    /// Total IPI devolution value in cents (vIPIDevol).
32    pub v_ipi_devol: i64,
33    /// Total PIS-ST value in cents (vPISST, accumulated from items with indSomaPISST=1).
34    pub v_pis_st: i64,
35    /// Total COFINS-ST value in cents (vCOFINSST, accumulated from items with indSomaCOFINSST=1).
36    pub v_cofins_st: i64,
37}
38
39/// Calculate `vNF` using the V1 method (from accumulated struct values).
40///
41/// Matches the PHP `buildTotalICMS()` formula:
42/// ```text
43/// vNF = vProd - vDesc - (vICMSDeson * indDeduzDeson)
44///     + vST + vFCPST + vICMSMonoReten
45///     + vFrete + vSeg + vOutro
46///     + vII + vIPI + vIPIDevol
47///     + vServ + vPISST + vCOFINSST
48/// ```
49fn calculate_v_nf_v1(
50    total_products: i64,
51    icms: &IcmsTotals,
52    other: &OtherTotals,
53    v_serv: i64,
54) -> i64 {
55    let deson_deduction = if icms.ind_deduz_deson {
56        icms.v_icms_deson.0
57    } else {
58        0
59    };
60
61    total_products - other.v_desc - deson_deduction
62        + icms.v_st.0
63        + icms.v_fcp_st.0
64        + icms.v_icms_mono_reten.0
65        + other.v_frete
66        + other.v_seg
67        + other.v_outro
68        + other.v_ii
69        + other.v_ipi
70        + other.v_ipi_devol
71        + other.v_pis_st
72        + other.v_cofins_st
73        + v_serv
74}
75
76/// Calculate `vNF` using the V2 method (from built XML tag values).
77///
78/// In the Rust implementation, since we accumulate from the same source
79/// data, V2 produces the same result as V1.  The function exists for API
80/// parity with PHP `sped-nfe` and for future divergence.
81fn calculate_v_nf_v2(
82    total_products: i64,
83    icms: &IcmsTotals,
84    other: &OtherTotals,
85    v_serv: i64,
86) -> i64 {
87    // V2 uses the same formula — the PHP difference is only in _where_
88    // the input values come from (DOM tags vs. accumulated array).
89    calculate_v_nf_v1(total_products, icms, other, v_serv)
90}
91
92/// Parse a decimal string (e.g. "100.50") into cents (i64).
93///
94/// Returns 0 when the string is empty or cannot be parsed.
95/// Used internally to extract numeric values from `IbsCbsTotData` / `IsTotData`
96/// for the `vNFTot` auto-calculation.
97fn parse_decimal_to_cents(s: &str) -> i64 {
98    if s.is_empty() {
99        return 0;
100    }
101    s.parse::<f64>()
102        .map(|v| (v * 100.0).round() as i64)
103        .unwrap_or(0)
104}
105
106/// Build the `<total>` element with ICMSTot, optional ISSQNtot, retTrib, ISTot, IBSCBSTot,
107/// and optional vNFTot (PL_010 only).
108///
109/// `schema_version` controls whether PL_010-exclusive totals (ISTot, IBSCBSTot, vNFTot) are
110/// emitted.
111///
112/// `v_nf_tot_override` allows the caller to supply a manual override for `vNFTot`, matching
113/// the PHP `tagTotal(vNFTot)` API.  When `None`, `vNFTot` is auto-calculated as
114/// `vNF + vIBS + vCBS + vIS` (only emitted when IBSCBSTot is present and the result > 0).
115#[allow(clippy::too_many_arguments)]
116pub fn build_total(
117    total_products: i64,
118    icms: &IcmsTotals,
119    other: &OtherTotals,
120    ret_trib: Option<&RetTribData>,
121    issqn_tot: Option<&IssqnTotData>,
122    is_tot: Option<&IsTotData>,
123    ibs_cbs_tot: Option<&IbsCbsTotData>,
124    schema_version: SchemaVersion,
125    calculation_method: CalculationMethod,
126    v_nf_tot_override: Option<Cents>,
127) -> String {
128    let fc2 = |c: i64| format_cents(c, 2);
129
130    // Extract vServ from ISSQNTot for the vNF formula
131    let v_serv = issqn_tot.and_then(|iq| iq.v_serv).map(|c| c.0).unwrap_or(0);
132
133    // Calculate vNF using the selected method.
134    let v_nf = match calculation_method {
135        CalculationMethod::V1 => calculate_v_nf_v1(total_products, icms, other, v_serv),
136        CalculationMethod::V2 => calculate_v_nf_v2(total_products, icms, other, v_serv),
137    };
138
139    // Optional ICMSTot fields — PHP sped-nfe omits these when <= 0
140    let mut icms_children = vec![
141        tag("vBC", &[], TagContent::Text(&fc2(icms.v_bc.0))),
142        tag("vICMS", &[], TagContent::Text(&fc2(icms.v_icms.0))),
143        tag(
144            "vICMSDeson",
145            &[],
146            TagContent::Text(&fc2(icms.v_icms_deson.0)),
147        ),
148    ];
149    // vFCPUFDest, vICMSUFDest, vICMSUFRemet: only included when > 0 (matches PHP)
150    if icms.v_fcp_uf_dest.0 > 0 {
151        icms_children.push(tag(
152            "vFCPUFDest",
153            &[],
154            TagContent::Text(&fc2(icms.v_fcp_uf_dest.0)),
155        ));
156    }
157    if icms.v_icms_uf_dest.0 > 0 {
158        icms_children.push(tag(
159            "vICMSUFDest",
160            &[],
161            TagContent::Text(&fc2(icms.v_icms_uf_dest.0)),
162        ));
163    }
164    if icms.v_icms_uf_remet.0 > 0 {
165        icms_children.push(tag(
166            "vICMSUFRemet",
167            &[],
168            TagContent::Text(&fc2(icms.v_icms_uf_remet.0)),
169        ));
170    }
171    icms_children.push(tag("vFCP", &[], TagContent::Text(&fc2(icms.v_fcp.0))));
172    icms_children.extend([
173        tag("vBCST", &[], TagContent::Text(&fc2(icms.v_bc_st.0))),
174        tag("vST", &[], TagContent::Text(&fc2(icms.v_st.0))),
175        tag("vFCPST", &[], TagContent::Text(&fc2(icms.v_fcp_st.0))),
176        tag(
177            "vFCPSTRet",
178            &[],
179            TagContent::Text(&fc2(icms.v_fcp_st_ret.0)),
180        ),
181    ]);
182
183    // ICMS monofásico (NT 2023.001): only included when > 0 (matches PHP)
184    if icms.q_bc_mono > 0 {
185        icms_children.push(tag("qBCMono", &[], TagContent::Text(&fc2(icms.q_bc_mono))));
186    }
187    if icms.v_icms_mono.0 > 0 {
188        icms_children.push(tag(
189            "vICMSMono",
190            &[],
191            TagContent::Text(&fc2(icms.v_icms_mono.0)),
192        ));
193    }
194    if icms.q_bc_mono_reten > 0 {
195        icms_children.push(tag(
196            "qBCMonoReten",
197            &[],
198            TagContent::Text(&fc2(icms.q_bc_mono_reten)),
199        ));
200    }
201    if icms.v_icms_mono_reten.0 > 0 {
202        icms_children.push(tag(
203            "vICMSMonoReten",
204            &[],
205            TagContent::Text(&fc2(icms.v_icms_mono_reten.0)),
206        ));
207    }
208    if icms.q_bc_mono_ret > 0 {
209        icms_children.push(tag(
210            "qBCMonoRet",
211            &[],
212            TagContent::Text(&fc2(icms.q_bc_mono_ret)),
213        ));
214    }
215    if icms.v_icms_mono_ret.0 > 0 {
216        icms_children.push(tag(
217            "vICMSMonoRet",
218            &[],
219            TagContent::Text(&fc2(icms.v_icms_mono_ret.0)),
220        ));
221    }
222
223    icms_children.extend([
224        tag("vProd", &[], TagContent::Text(&fc2(total_products))),
225        tag("vFrete", &[], TagContent::Text(&fc2(other.v_frete))),
226        tag("vSeg", &[], TagContent::Text(&fc2(other.v_seg))),
227        tag("vDesc", &[], TagContent::Text(&fc2(other.v_desc))),
228        tag("vII", &[], TagContent::Text(&fc2(other.v_ii))),
229        tag("vIPI", &[], TagContent::Text(&fc2(other.v_ipi))),
230        tag("vIPIDevol", &[], TagContent::Text(&fc2(other.v_ipi_devol))),
231        tag("vPIS", &[], TagContent::Text(&fc2(other.v_pis))),
232        tag("vCOFINS", &[], TagContent::Text(&fc2(other.v_cofins))),
233        tag("vOutro", &[], TagContent::Text(&fc2(other.v_outro))),
234        tag("vNF", &[], TagContent::Text(&fc2(v_nf))),
235    ]);
236
237    // vTotTrib: only included when > 0 (matches PHP)
238    if other.v_tot_trib > 0 {
239        icms_children.push(tag(
240            "vTotTrib",
241            &[],
242            TagContent::Text(&fc2(other.v_tot_trib)),
243        ));
244    }
245
246    let icms_tot = tag("ICMSTot", &[], TagContent::Children(icms_children));
247
248    let mut total_children = vec![icms_tot];
249
250    // ISSQNtot — emitted when the invoice has service items subject to ISSQN
251    if let Some(iqt) = issqn_tot {
252        total_children.push(build_issqn_tot(iqt));
253    }
254
255    // ISTot, IBSCBSTot, and vNFTot — only emitted when schema is PL_010 or later
256    // (matching PHP: $this->schema > 9)
257    if schema_version.is_pl010() {
258        // ISTot — emitted when IS (Imposto Seletivo) is present
259        if let Some(ist) = is_tot {
260            total_children.push(tax_ibs_cbs::build_is_tot_xml(ist));
261        }
262
263        // IBSCBSTot — emitted when IBS/CBS is present
264        if let Some(ibst) = ibs_cbs_tot {
265            total_children.push(tax_ibs_cbs::build_ibs_cbs_tot_xml(ibst));
266
267            // vNFTot — emitted after IBSCBSTot (matching PHP addTagTotal position).
268            // When override is provided and non-zero, use it directly.
269            // Otherwise, auto-calculate as vNF + vIBS + vCBS + vIS.
270            let v_nf_tot_value = if let Some(ov) = v_nf_tot_override {
271                // Override provided — use it (PHP errors on zero, we skip emission)
272                ov.0
273            } else {
274                // Auto-calculate: vNFTot = vNF + vIBS + vCBS + vIS
275                let v_ibs = ibst
276                    .g_ibs_v_ibs
277                    .as_deref()
278                    .map(parse_decimal_to_cents)
279                    .unwrap_or(0);
280                let v_cbs = ibst
281                    .g_cbs_v_cbs
282                    .as_deref()
283                    .map(parse_decimal_to_cents)
284                    .unwrap_or(0);
285                let v_is = is_tot
286                    .map(|ist| parse_decimal_to_cents(&ist.v_is))
287                    .unwrap_or(0);
288                v_nf + v_ibs + v_cbs + v_is
289            };
290
291            // Only emit when > 0 (matches PHP: empty check)
292            if v_nf_tot_value > 0 {
293                total_children.push(tag("vNFTot", &[], TagContent::Text(&fc2(v_nf_tot_value))));
294            }
295        }
296    }
297
298    if let Some(rt) = ret_trib {
299        let opt_tag = |name: &str, val: Option<crate::newtypes::Cents>| -> Option<String> {
300            val.map(|v| tag(name, &[], TagContent::Text(&fc2(v.0))))
301        };
302        let ret_children: Vec<String> = [
303            opt_tag("vRetPIS", rt.v_ret_pis),
304            opt_tag("vRetCOFINS", rt.v_ret_cofins),
305            opt_tag("vRetCSLL", rt.v_ret_csll),
306            opt_tag("vBCIRRF", rt.v_bc_irrf),
307            opt_tag("vIRRF", rt.v_irrf),
308            opt_tag("vBCRetPrev", rt.v_bc_ret_prev),
309            opt_tag("vRetPrev", rt.v_ret_prev),
310        ]
311        .into_iter()
312        .flatten()
313        .collect();
314
315        if !ret_children.is_empty() {
316            total_children.push(tag("retTrib", &[], TagContent::Children(ret_children)));
317        }
318    }
319
320    tag("total", &[], TagContent::Children(total_children))
321}
322
323/// Build the `<ISSQNtot>` element.
324///
325/// Matches PHP sped-nfe `tagISSQNTot`: monetary fields are only emitted when > 0;
326/// `dCompet` is always emitted; `cRegTrib` is optional.
327fn build_issqn_tot(data: &IssqnTotData) -> String {
328    let fc2 = |c: i64| format_cents(c, 2);
329
330    // Helper: emit a tag only when the Cents value is > 0
331    let opt_cents = |name: &str, val: Option<Cents>| -> Option<String> {
332        val.and_then(|c| {
333            if c.0 > 0 {
334                Some(tag(name, &[], TagContent::Text(&fc2(c.0))))
335            } else {
336                None
337            }
338        })
339    };
340
341    let mut children: Vec<String> = Vec::new();
342
343    if let Some(t) = opt_cents("vServ", data.v_serv) {
344        children.push(t);
345    }
346    if let Some(t) = opt_cents("vBC", data.v_bc) {
347        children.push(t);
348    }
349    if let Some(t) = opt_cents("vISS", data.v_iss) {
350        children.push(t);
351    }
352    if let Some(t) = opt_cents("vPIS", data.v_pis) {
353        children.push(t);
354    }
355    if let Some(t) = opt_cents("vCOFINS", data.v_cofins) {
356        children.push(t);
357    }
358
359    // dCompet is always present (required)
360    children.push(tag("dCompet", &[], TagContent::Text(&data.d_compet)));
361
362    if let Some(t) = opt_cents("vDeducao", data.v_deducao) {
363        children.push(t);
364    }
365    if let Some(t) = opt_cents("vOutro", data.v_outro) {
366        children.push(t);
367    }
368    if let Some(t) = opt_cents("vDescIncond", data.v_desc_incond) {
369        children.push(t);
370    }
371    if let Some(t) = opt_cents("vDescCond", data.v_desc_cond) {
372        children.push(t);
373    }
374    if let Some(t) = opt_cents("vISSRet", data.v_iss_ret) {
375        children.push(t);
376    }
377
378    if let Some(ref reg) = data.c_reg_trib {
379        children.push(tag("cRegTrib", &[], TagContent::Text(reg)));
380    }
381
382    tag("ISSQNtot", &[], TagContent::Children(children))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::newtypes::Cents;
389    use crate::tax_icms::IcmsTotals;
390    use crate::types::IssqnTotData;
391
392    fn zero_icms() -> IcmsTotals {
393        IcmsTotals::default()
394    }
395
396    fn zero_other() -> OtherTotals {
397        OtherTotals {
398            v_ipi: 0,
399            v_pis: 0,
400            v_cofins: 0,
401            v_ii: 0,
402            v_frete: 0,
403            v_seg: 0,
404            v_desc: 0,
405            v_outro: 0,
406            v_tot_trib: 0,
407            v_ipi_devol: 0,
408            v_pis_st: 0,
409            v_cofins_st: 0,
410        }
411    }
412
413    /// Default calculation method for tests (V2, matching PHP default).
414    fn default_method() -> CalculationMethod {
415        CalculationMethod::V2
416    }
417
418    #[test]
419    fn issqn_tot_minimal_only_dcompet() {
420        let data = IssqnTotData::new("2026-03-12");
421        let xml = build_issqn_tot(&data);
422
423        assert_eq!(xml, "<ISSQNtot><dCompet>2026-03-12</dCompet></ISSQNtot>");
424    }
425
426    #[test]
427    fn issqn_tot_with_all_positive_values() {
428        let data = IssqnTotData::new("2026-03-12")
429            .v_serv(Cents(100000))
430            .v_bc(Cents(100000))
431            .v_iss(Cents(5000))
432            .v_pis(Cents(1650))
433            .v_cofins(Cents(7600))
434            .v_deducao(Cents(2000))
435            .v_outro(Cents(500))
436            .v_desc_incond(Cents(300))
437            .v_desc_cond(Cents(200))
438            .v_iss_ret(Cents(1000))
439            .c_reg_trib("6");
440
441        let xml = build_issqn_tot(&data);
442
443        assert_eq!(
444            xml,
445            "<ISSQNtot>\
446                <vServ>1000.00</vServ>\
447                <vBC>1000.00</vBC>\
448                <vISS>50.00</vISS>\
449                <vPIS>16.50</vPIS>\
450                <vCOFINS>76.00</vCOFINS>\
451                <dCompet>2026-03-12</dCompet>\
452                <vDeducao>20.00</vDeducao>\
453                <vOutro>5.00</vOutro>\
454                <vDescIncond>3.00</vDescIncond>\
455                <vDescCond>2.00</vDescCond>\
456                <vISSRet>10.00</vISSRet>\
457                <cRegTrib>6</cRegTrib>\
458            </ISSQNtot>"
459        );
460    }
461
462    #[test]
463    fn issqn_tot_zero_values_omitted() {
464        let data = IssqnTotData::new("2026-01-01")
465            .v_serv(Cents(0))
466            .v_bc(Cents(0))
467            .v_iss(Cents(0));
468
469        let xml = build_issqn_tot(&data);
470
471        // Zero values should NOT appear (matching PHP behavior)
472        assert_eq!(xml, "<ISSQNtot><dCompet>2026-01-01</dCompet></ISSQNtot>");
473    }
474
475    #[test]
476    fn issqn_tot_in_total_element() {
477        let data = IssqnTotData::new("2026-03-12")
478            .v_serv(Cents(50000))
479            .v_bc(Cents(50000))
480            .v_iss(Cents(2500));
481
482        let xml = build_total(
483            0,
484            &zero_icms(),
485            &zero_other(),
486            None,
487            Some(&data),
488            None,
489            None,
490            SchemaVersion::PL009,
491            default_method(),
492            None,
493        );
494
495        // ISSQNtot should appear after ICMSTot
496        let icms_end = xml.find("</ICMSTot>").expect("</ICMSTot> must exist");
497        let issqn_start = xml.find("<ISSQNtot>").expect("<ISSQNtot> must exist");
498        assert!(issqn_start > icms_end, "ISSQNtot must come after ICMSTot");
499
500        assert!(xml.contains("<vServ>500.00</vServ>"));
501        assert!(xml.contains("<vBC>500.00</vBC>"));
502        assert!(xml.contains("<vISS>25.00</vISS>"));
503        assert!(xml.contains("<dCompet>2026-03-12</dCompet>"));
504    }
505
506    #[test]
507    fn no_issqn_tot_when_none() {
508        let xml = build_total(
509            0,
510            &zero_icms(),
511            &zero_other(),
512            None,
513            None,
514            None,
515            None,
516            SchemaVersion::PL009,
517            default_method(),
518            None,
519        );
520        assert!(!xml.contains("<ISSQNtot>"));
521    }
522
523    #[test]
524    fn icms_mono_fields_emitted_when_positive() {
525        let mut icms = zero_icms();
526        icms.q_bc_mono = 10000;
527        icms.v_icms_mono = Cents(5000);
528        icms.q_bc_mono_reten = 8000;
529        icms.v_icms_mono_reten = Cents(4000);
530        icms.q_bc_mono_ret = 6000;
531        icms.v_icms_mono_ret = Cents(3000);
532
533        let xml = build_total(
534            100000,
535            &icms,
536            &zero_other(),
537            None,
538            None,
539            None,
540            None,
541            SchemaVersion::PL009,
542            default_method(),
543            None,
544        );
545
546        // All monophasic fields must be present
547        assert!(xml.contains("<qBCMono>100.00</qBCMono>"));
548        assert!(xml.contains("<vICMSMono>50.00</vICMSMono>"));
549        assert!(xml.contains("<qBCMonoReten>80.00</qBCMonoReten>"));
550        assert!(xml.contains("<vICMSMonoReten>40.00</vICMSMonoReten>"));
551        assert!(xml.contains("<qBCMonoRet>60.00</qBCMonoRet>"));
552        assert!(xml.contains("<vICMSMonoRet>30.00</vICMSMonoRet>"));
553
554        // Verify position: monophasic fields must come after vFCPSTRet and before vProd
555        let fcp_st_ret_end = xml.find("</vFCPSTRet>").expect("vFCPSTRet must exist");
556        let q_bc_mono_pos = xml.find("<qBCMono>").expect("qBCMono must exist");
557        let v_prod_pos = xml.find("<vProd>").expect("vProd must exist");
558        assert!(
559            fcp_st_ret_end < q_bc_mono_pos,
560            "qBCMono must come after vFCPSTRet"
561        );
562        assert!(q_bc_mono_pos < v_prod_pos, "qBCMono must come before vProd");
563    }
564
565    #[test]
566    fn icms_mono_fields_omitted_when_zero() {
567        let xml = build_total(
568            100000,
569            &zero_icms(),
570            &zero_other(),
571            None,
572            None,
573            None,
574            None,
575            SchemaVersion::PL009,
576            default_method(),
577            None,
578        );
579
580        assert!(
581            !xml.contains("<qBCMono>"),
582            "qBCMono must be omitted when zero"
583        );
584        assert!(
585            !xml.contains("<vICMSMono>"),
586            "vICMSMono must be omitted when zero"
587        );
588        assert!(
589            !xml.contains("<qBCMonoReten>"),
590            "qBCMonoReten must be omitted when zero"
591        );
592        assert!(
593            !xml.contains("<vICMSMonoReten>"),
594            "vICMSMonoReten must be omitted when zero"
595        );
596        assert!(
597            !xml.contains("<qBCMonoRet>"),
598            "qBCMonoRet must be omitted when zero"
599        );
600        assert!(
601            !xml.contains("<vICMSMonoRet>"),
602            "vICMSMonoRet must be omitted when zero"
603        );
604    }
605
606    #[test]
607    fn icms_mono_partial_fields_emitted() {
608        let mut icms = zero_icms();
609        // Only set vICMSMono and qBCMonoRet
610        icms.v_icms_mono = Cents(2500);
611        icms.q_bc_mono_ret = 5000;
612
613        let xml = build_total(
614            100000,
615            &icms,
616            &zero_other(),
617            None,
618            None,
619            None,
620            None,
621            SchemaVersion::PL009,
622            default_method(),
623            None,
624        );
625
626        // q_bc_mono is 0, should be omitted
627        assert!(!xml.contains("<qBCMono>"));
628        // v_icms_mono > 0, should be present
629        assert!(xml.contains("<vICMSMono>25.00</vICMSMono>"));
630        // q_bc_mono_reten is 0, should be omitted
631        assert!(!xml.contains("<qBCMonoReten>"));
632        // v_icms_mono_reten is 0, should be omitted
633        assert!(!xml.contains("<vICMSMonoReten>"));
634        // q_bc_mono_ret > 0, should be present
635        assert!(xml.contains("<qBCMonoRet>50.00</qBCMonoRet>"));
636        // v_icms_mono_ret is 0, should be omitted
637        assert!(!xml.contains("<vICMSMonoRet>"));
638    }
639
640    #[test]
641    fn issqn_tot_creg_trib_without_monetary_values() {
642        let data = IssqnTotData::new("2026-06-15").c_reg_trib("1");
643        let xml = build_issqn_tot(&data);
644
645        assert_eq!(
646            xml,
647            "<ISSQNtot>\
648                <dCompet>2026-06-15</dCompet>\
649                <cRegTrib>1</cRegTrib>\
650            </ISSQNtot>"
651        );
652    }
653
654    /// Quando indDeduzDeson=1 e vICMSDeson=100.00 (10000 cents),
655    /// vNF deve ser reduzido em 100.00.
656    #[test]
657    fn vnf_reduced_by_vicms_deson_when_ind_deduz_deson_is_true() {
658        // vProd = 1000.00
659        let total_products: i64 = 100_000;
660        let mut icms = zero_icms();
661        icms.v_icms_deson = Cents(10_000); // 100.00
662        icms.ind_deduz_deson = true;
663
664        let xml = build_total(
665            total_products,
666            &icms,
667            &zero_other(),
668            None,
669            None,
670            None,
671            None,
672            SchemaVersion::PL009,
673            default_method(),
674            None,
675        );
676
677        // vNF = vProd - vDesc - vICMSDeson + ... = 1000.00 - 0 - 100.00 = 900.00
678        assert_eq!(
679            xml.contains("<vNF>900.00</vNF>"),
680            true,
681            "vNF deve ser 900.00 quando indDeduzDeson=1 e vICMSDeson=100.00. XML: {}",
682            xml
683        );
684    }
685
686    /// Quando indDeduzDeson=false (padrão), vICMSDeson NÃO é subtraído de vNF.
687    #[test]
688    fn vnf_unchanged_without_desoneracao() {
689        let total_products: i64 = 100_000;
690        let mut icms = zero_icms();
691        icms.v_icms_deson = Cents(10_000); // 100.00 — presente mas não deduzido
692        icms.ind_deduz_deson = false;
693
694        let xml = build_total(
695            total_products,
696            &icms,
697            &zero_other(),
698            None,
699            None,
700            None,
701            None,
702            SchemaVersion::PL009,
703            default_method(),
704            None,
705        );
706
707        // vNF = vProd = 1000.00 (desoneração NÃO deduzida)
708        assert_eq!(
709            xml.contains("<vNF>1000.00</vNF>"),
710            true,
711            "vNF deve ser 1000.00 quando indDeduzDeson=false. XML: {}",
712            xml
713        );
714    }
715
716    /// vServ do ISSQNTot deve ser somado ao vNF.
717    #[test]
718    fn vnf_includes_vserv_from_issqn() {
719        let total_products: i64 = 100_000; // vProd = 1000.00
720        let issqn = IssqnTotData::new("2026-03-12").v_serv(Cents(50_000)); // vServ = 500.00
721
722        let xml = build_total(
723            total_products,
724            &zero_icms(),
725            &zero_other(),
726            None,
727            Some(&issqn),
728            None,
729            None,
730            SchemaVersion::PL009,
731            default_method(),
732            None,
733        );
734
735        // vNF = vProd + vServ = 1000.00 + 500.00 = 1500.00
736        assert_eq!(
737            xml.contains("<vNF>1500.00</vNF>"),
738            true,
739            "vNF deve ser 1500.00 incluindo vServ=500.00. XML: {}",
740            xml
741        );
742    }
743
744    // ── vFCPUFDest, vICMSUFDest, vICMSUFRemet emitted when > 0 ─────────
745
746    #[test]
747    fn icms_uf_dest_fields_emitted_when_positive() {
748        let mut icms = zero_icms();
749        icms.v_fcp_uf_dest = Cents(1000);
750        icms.v_icms_uf_dest = Cents(2000);
751        icms.v_icms_uf_remet = Cents(3000);
752
753        let xml = build_total(
754            100000,
755            &icms,
756            &zero_other(),
757            None,
758            None,
759            None,
760            None,
761            SchemaVersion::PL009,
762            default_method(),
763            None,
764        );
765
766        assert!(
767            xml.contains("<vFCPUFDest>10.00</vFCPUFDest>"),
768            "vFCPUFDest must be present when > 0. XML: {xml}"
769        );
770        assert!(
771            xml.contains("<vICMSUFDest>20.00</vICMSUFDest>"),
772            "vICMSUFDest must be present when > 0. XML: {xml}"
773        );
774        assert!(
775            xml.contains("<vICMSUFRemet>30.00</vICMSUFRemet>"),
776            "vICMSUFRemet must be present when > 0. XML: {xml}"
777        );
778
779        // Verify ordering: these fields come after vICMSDeson and before vFCP
780        let deson_end = xml.find("</vICMSDeson>").expect("vICMSDeson must exist");
781        let fcp_uf_start = xml.find("<vFCPUFDest>").expect("vFCPUFDest must exist");
782        let fcp_start = xml.find("<vFCP>").expect("vFCP must exist");
783        assert!(
784            fcp_uf_start > deson_end,
785            "vFCPUFDest must come after vICMSDeson"
786        );
787        assert!(fcp_start > fcp_uf_start, "vFCP must come after vFCPUFDest");
788    }
789
790    #[test]
791    fn icms_uf_dest_fields_omitted_when_zero() {
792        let xml = build_total(
793            100000,
794            &zero_icms(),
795            &zero_other(),
796            None,
797            None,
798            None,
799            None,
800            SchemaVersion::PL009,
801            default_method(),
802            None,
803        );
804
805        assert!(
806            !xml.contains("<vFCPUFDest>"),
807            "vFCPUFDest must be omitted when zero"
808        );
809        assert!(
810            !xml.contains("<vICMSUFDest>"),
811            "vICMSUFDest must be omitted when zero"
812        );
813        assert!(
814            !xml.contains("<vICMSUFRemet>"),
815            "vICMSUFRemet must be omitted when zero"
816        );
817    }
818
819    // ── vNFTot (PL_010 only) ─────────────────────────────────────────────
820
821    #[test]
822    fn v_nf_tot_override_emitted_on_pl010_with_ibs_cbs() {
823        use crate::tax_ibs_cbs::IbsCbsTotData;
824
825        let ibs_cbs = IbsCbsTotData::new("1000.00");
826        let xml = build_total(
827            100_000, // vProd = 1000.00
828            &zero_icms(),
829            &zero_other(),
830            None,
831            None,
832            None,
833            Some(&ibs_cbs),
834            SchemaVersion::PL010,
835            default_method(),
836            Some(Cents(150_000)), // override = 1500.00
837        );
838
839        assert!(
840            xml.contains("<vNFTot>1500.00</vNFTot>"),
841            "vNFTot override deve ser emitido com valor 1500.00. XML: {xml}"
842        );
843
844        // vNFTot deve vir depois de </IBSCBSTot>
845        let ibs_end = xml.find("</IBSCBSTot>").expect("</IBSCBSTot> must exist");
846        let vnftot_start = xml.find("<vNFTot>").expect("<vNFTot> must exist");
847        assert!(
848            vnftot_start > ibs_end,
849            "vNFTot deve vir depois de IBSCBSTot"
850        );
851    }
852
853    #[test]
854    fn v_nf_tot_auto_calculated_on_pl010() {
855        use crate::tax_ibs_cbs::{IbsCbsTotData, IsTotData};
856
857        // vProd = 1000.00, vNF será 1000.00
858        // vIBS = 50.00, vCBS = 30.00, vIS = 10.00
859        // vNFTot auto = 1000.00 + 50.00 + 30.00 + 10.00 = 1090.00
860        let mut ibs_cbs = IbsCbsTotData::new("1000.00");
861        ibs_cbs.g_ibs_v_ibs = Some("50.00".to_string());
862        ibs_cbs.g_cbs_v_cbs = Some("30.00".to_string());
863
864        let is_tot = IsTotData::new("10.00");
865
866        let xml = build_total(
867            100_000, // vProd = 1000.00
868            &zero_icms(),
869            &zero_other(),
870            None,
871            None,
872            Some(&is_tot),
873            Some(&ibs_cbs),
874            SchemaVersion::PL010,
875            default_method(),
876            None, // sem override — auto-cálculo
877        );
878
879        assert!(
880            xml.contains("<vNFTot>1090.00</vNFTot>"),
881            "vNFTot auto-calculado deve ser 1090.00. XML: {xml}"
882        );
883    }
884
885    #[test]
886    fn v_nf_tot_not_emitted_on_pl009() {
887        use crate::tax_ibs_cbs::IbsCbsTotData;
888
889        let ibs_cbs = IbsCbsTotData::new("1000.00");
890        let xml = build_total(
891            100_000,
892            &zero_icms(),
893            &zero_other(),
894            None,
895            None,
896            None,
897            Some(&ibs_cbs),
898            SchemaVersion::PL009,
899            default_method(),
900            Some(Cents(150_000)),
901        );
902
903        assert!(
904            !xml.contains("<vNFTot>"),
905            "vNFTot não deve ser emitido no PL009. XML: {xml}"
906        );
907    }
908
909    #[test]
910    fn v_nf_tot_not_emitted_without_ibs_cbs() {
911        let xml = build_total(
912            100_000,
913            &zero_icms(),
914            &zero_other(),
915            None,
916            None,
917            None,
918            None, // sem IBSCBSTot
919            SchemaVersion::PL010,
920            default_method(),
921            Some(Cents(150_000)),
922        );
923
924        assert!(
925            !xml.contains("<vNFTot>"),
926            "vNFTot não deve ser emitido sem IBSCBSTot. XML: {xml}"
927        );
928    }
929
930    #[test]
931    fn v_nf_tot_zero_override_not_emitted() {
932        use crate::tax_ibs_cbs::IbsCbsTotData;
933
934        let ibs_cbs = IbsCbsTotData::new("1000.00");
935        let xml = build_total(
936            100_000,
937            &zero_icms(),
938            &zero_other(),
939            None,
940            None,
941            None,
942            Some(&ibs_cbs),
943            SchemaVersion::PL010,
944            default_method(),
945            Some(Cents(0)), // override = zero
946        );
947
948        assert!(
949            !xml.contains("<vNFTot>"),
950            "vNFTot não deve ser emitido quando override é zero. XML: {xml}"
951        );
952    }
953
954    #[test]
955    fn v_nf_tot_auto_without_is() {
956        use crate::tax_ibs_cbs::IbsCbsTotData;
957
958        // vProd = 500.00, vNF será 500.00
959        // vIBS = 25.00, vCBS = 15.00, sem IS
960        // vNFTot auto = 500.00 + 25.00 + 15.00 = 540.00
961        let mut ibs_cbs = IbsCbsTotData::new("500.00");
962        ibs_cbs.g_ibs_v_ibs = Some("25.00".to_string());
963        ibs_cbs.g_cbs_v_cbs = Some("15.00".to_string());
964
965        let xml = build_total(
966            50_000, // vProd = 500.00
967            &zero_icms(),
968            &zero_other(),
969            None,
970            None,
971            None,
972            Some(&ibs_cbs),
973            SchemaVersion::PL010,
974            default_method(),
975            None,
976        );
977
978        assert!(
979            xml.contains("<vNFTot>540.00</vNFTot>"),
980            "vNFTot auto-calculado sem IS deve ser 540.00. XML: {xml}"
981        );
982    }
983}