Skip to main content

fiscal_core/xml_builder/
optional.rs

1//! Build optional XML groups: cobr, infAdic, infIntermed, exporta, compra,
2//! infRespTec, retirada, entrega, autXML.
3
4use super::tax_id::TaxId;
5use crate::format_utils::format_cents;
6use crate::types::*;
7use crate::xml_utils::{TagContent, tag};
8use base64::Engine as _;
9
10/// Build `<cobr>` (billing) element.
11pub fn build_cobr(billing: &BillingData) -> String {
12    let fc2 = |c: i64| format_cents(c, 2);
13    let mut children = Vec::new();
14
15    if let Some(ref inv) = billing.invoice {
16        let mut fat_children = vec![
17            tag("nFat", &[], TagContent::Text(&inv.number)),
18            tag("vOrig", &[], TagContent::Text(&fc2(inv.original_value.0))),
19        ];
20        if let Some(disc) = inv.discount_value {
21            fat_children.push(tag("vDesc", &[], TagContent::Text(&fc2(disc.0))));
22        }
23        fat_children.push(tag("vLiq", &[], TagContent::Text(&fc2(inv.net_value.0))));
24        children.push(tag("fat", &[], TagContent::Children(fat_children)));
25    }
26
27    if let Some(ref installments) = billing.installments {
28        for inst in installments {
29            children.push(tag(
30                "dup",
31                &[],
32                TagContent::Children(vec![
33                    tag("nDup", &[], TagContent::Text(&inst.number)),
34                    tag("dVenc", &[], TagContent::Text(&inst.due_date)),
35                    tag("vDup", &[], TagContent::Text(&fc2(inst.value.0))),
36                ]),
37            ));
38        }
39    }
40
41    tag("cobr", &[], TagContent::Children(children))
42}
43
44/// Build `<infAdic>` (additional info) element.
45pub(crate) fn build_inf_adic(data: &InvoiceBuildData) -> String {
46    let mut notes: Vec<String> = Vec::new();
47
48    if let Some(ref cont) = data.contingency {
49        notes.push(format!(
50            "Emitida em contingencia ({}). Motivo: {}",
51            cont.contingency_type.as_str(),
52            cont.reason
53        ));
54    }
55
56    // PHP does NOT auto-add homologation note to infAdic — removed to match PHP
57
58    let add_info = data.additional_info.as_ref();
59    let has_additional = add_info.is_some_and(|a| {
60        a.taxpayer_note.is_some()
61            || a.tax_authority_note.is_some()
62            || a.contributor_obs.as_ref().is_some_and(|v| !v.is_empty())
63            || a.fiscal_obs.as_ref().is_some_and(|v| !v.is_empty())
64            || a.process_refs.as_ref().is_some_and(|v| !v.is_empty())
65    });
66
67    if notes.is_empty() && !has_additional {
68        return String::new();
69    }
70
71    let mut children = Vec::new();
72
73    // infAdFisco before infCpl per schema
74    if let Some(note) = add_info.and_then(|a| a.tax_authority_note.as_ref()) {
75        children.push(tag("infAdFisco", &[], TagContent::Text(note)));
76    }
77
78    // Merge contingency/env notes with taxpayer note
79    if let Some(tn) = add_info.and_then(|a| a.taxpayer_note.as_ref()) {
80        notes.push(tn.to_string());
81    }
82    if !notes.is_empty() {
83        children.push(tag("infCpl", &[], TagContent::Text(&notes.join("; "))));
84    }
85
86    // obsCont (max 10)
87    if let Some(obs_list) = add_info.and_then(|a| a.contributor_obs.as_ref()) {
88        for obs in obs_list.iter().take(10) {
89            children.push(tag(
90                "obsCont",
91                &[("xCampo", &obs.field)],
92                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&obs.text))]),
93            ));
94        }
95    }
96
97    // obsFisco (max 10)
98    if let Some(obs_list) = add_info.and_then(|a| a.fiscal_obs.as_ref()) {
99        for obs in obs_list.iter().take(10) {
100            children.push(tag(
101                "obsFisco",
102                &[("xCampo", &obs.field)],
103                TagContent::Children(vec![tag("xTexto", &[], TagContent::Text(&obs.text))]),
104            ));
105        }
106    }
107
108    // procRef (max 100)
109    if let Some(procs) = add_info.and_then(|a| a.process_refs.as_ref()) {
110        for p in procs.iter().take(100) {
111            let mut proc_children = vec![
112                tag("nProc", &[], TagContent::Text(&p.number)),
113                tag("indProc", &[], TagContent::Text(&p.origin)),
114            ];
115            if let Some(tp_ato) = &p.tp_ato {
116                proc_children.push(tag("tpAto", &[], TagContent::Text(tp_ato)));
117            }
118            children.push(tag("procRef", &[], TagContent::Children(proc_children)));
119        }
120    }
121
122    tag("infAdic", &[], TagContent::Children(children))
123}
124
125/// Build `<infIntermed>` element.
126pub fn build_intermediary(intermed: &IntermediaryData) -> String {
127    let mut children = vec![tag("CNPJ", &[], TagContent::Text(&intermed.tax_id))];
128    if let Some(ref id) = intermed.id_cad_int_tran {
129        children.push(tag("idCadIntTran", &[], TagContent::Text(id)));
130    }
131    tag("infIntermed", &[], TagContent::Children(children))
132}
133
134/// Build `<infRespTec>` element.
135///
136/// When both `csrt` and `csrt_id` are present, generates `<idCSRT>` and
137/// `<hashCSRT>` tags. The hash follows the PHP sped-nfe algorithm:
138/// `base64(sha1(CSRT + chNFe, raw_binary))`.
139pub fn build_tech_responsible(tech: &TechResponsibleData) -> String {
140    build_tech_responsible_with_key(tech, "")
141}
142
143/// Build `<infRespTec>` element with access key for CSRT hash computation.
144///
145/// When both `csrt` and `csrt_id` are present on `tech`, generates `<idCSRT>` and
146/// `<hashCSRT>` tags. The hash follows the PHP sped-nfe algorithm:
147/// `base64(sha1(CSRT + chNFe, raw_binary))`.
148pub fn build_tech_responsible_with_key(tech: &TechResponsibleData, access_key: &str) -> String {
149    let mut children = vec![
150        tag("CNPJ", &[], TagContent::Text(&tech.tax_id)),
151        tag("xContato", &[], TagContent::Text(&tech.contact)),
152        tag("email", &[], TagContent::Text(&tech.email)),
153    ];
154    if let Some(ref phone) = tech.phone {
155        children.push(tag("fone", &[], TagContent::Text(phone)));
156    }
157    if let (Some(csrt), Some(csrt_id)) = (&tech.csrt, &tech.csrt_id) {
158        if !access_key.is_empty() {
159            children.push(tag("idCSRT", &[], TagContent::Text(csrt_id)));
160            let hash = compute_hash_csrt(csrt, access_key);
161            children.push(tag("hashCSRT", &[], TagContent::Text(&hash)));
162        }
163    }
164    tag("infRespTec", &[], TagContent::Children(children))
165}
166
167/// Compute hashCSRT as defined by the SEFAZ specification.
168///
169/// Algorithm: `base64(sha1(CSRT + chNFe))` — matching PHP's
170/// `base64_encode(sha1($CSRT . $this->chNFe, true))`.
171fn compute_hash_csrt(csrt: &str, access_key: &str) -> String {
172    use sha1::{Digest, Sha1};
173    let combined = format!("{csrt}{access_key}");
174    let mut hasher = Sha1::new();
175    hasher.update(combined.as_bytes());
176    let raw_hash = hasher.finalize();
177    base64::engine::general_purpose::STANDARD.encode(raw_hash)
178}
179
180/// Build `<compra>` (purchase) element.
181pub fn build_purchase(purchase: &PurchaseData) -> String {
182    let mut children = Vec::new();
183    if let Some(ref note) = purchase.purchase_note {
184        children.push(tag("xNEmp", &[], TagContent::Text(note)));
185    }
186    if let Some(ref order) = purchase.order_number {
187        children.push(tag("xPed", &[], TagContent::Text(order)));
188    }
189    if let Some(ref contract) = purchase.contract_number {
190        children.push(tag("xCont", &[], TagContent::Text(contract)));
191    }
192    tag("compra", &[], TagContent::Children(children))
193}
194
195/// Build `<exporta>` element.
196pub fn build_export(exp: &ExportData) -> String {
197    let mut children = vec![
198        tag("UFSaidaPais", &[], TagContent::Text(&exp.exit_state)),
199        tag("xLocExporta", &[], TagContent::Text(&exp.export_location)),
200    ];
201    if let Some(ref dispatch) = exp.dispatch_location {
202        children.push(tag("xLocDespacho", &[], TagContent::Text(dispatch)));
203    }
204    tag("exporta", &[], TagContent::Children(children))
205}
206
207/// Build `<retirada>` (withdrawal) element.
208///
209/// Ordem das tags conforme PHP sped-nfe `tagretirada()`:
210/// CPF|CNPJ, xNome, xLgr, nro, xCpl, xBairro, cMun, xMun, UF, CEP,
211/// cPais, xPais, fone, email, IE.
212pub fn build_withdrawal(w: &LocationData) -> String {
213    tag(
214        "retirada",
215        &[],
216        TagContent::Children(build_location_children(w)),
217    )
218}
219
220/// Build `<entrega>` (delivery) element.
221///
222/// Ordem das tags conforme PHP sped-nfe `tagentrega()`:
223/// CPF|CNPJ, xNome, xLgr, nro, xCpl, xBairro, cMun, xMun, UF, CEP,
224/// cPais, xPais, fone, email, IE.
225pub fn build_delivery(d: &LocationData) -> String {
226    tag(
227        "entrega",
228        &[],
229        TagContent::Children(build_location_children(d)),
230    )
231}
232
233/// Constrói a lista de tags-filhas comuns a `<retirada>` e `<entrega>`,
234/// respeitando a ordem do schema NFe / PHP sped-nfe.
235fn build_location_children(loc: &LocationData) -> Vec<String> {
236    let tid = TaxId::new(&loc.tax_id);
237    let padded = tid.padded();
238    let mut children = vec![tag(tid.tag_name(), &[], TagContent::Text(&padded))];
239    if let Some(ref name) = loc.name {
240        children.push(tag("xNome", &[], TagContent::Text(name)));
241    }
242    children.push(tag("xLgr", &[], TagContent::Text(&loc.street)));
243    children.push(tag("nro", &[], TagContent::Text(&loc.number)));
244    if let Some(ref cpl) = loc.complement {
245        children.push(tag("xCpl", &[], TagContent::Text(cpl)));
246    }
247    children.push(tag("xBairro", &[], TagContent::Text(&loc.district)));
248    children.push(tag("cMun", &[], TagContent::Text(&loc.city_code.0)));
249    children.push(tag("xMun", &[], TagContent::Text(&loc.city_name)));
250    children.push(tag("UF", &[], TagContent::Text(&loc.state_code)));
251    if let Some(ref cep) = loc.zip_code {
252        children.push(tag("CEP", &[], TagContent::Text(cep)));
253    }
254    if let Some(ref c_pais) = loc.c_pais {
255        children.push(tag("cPais", &[], TagContent::Text(c_pais)));
256    }
257    if let Some(ref x_pais) = loc.x_pais {
258        children.push(tag("xPais", &[], TagContent::Text(x_pais)));
259    }
260    if let Some(ref fone) = loc.fone {
261        children.push(tag("fone", &[], TagContent::Text(fone)));
262    }
263    if let Some(ref email) = loc.email {
264        children.push(tag("email", &[], TagContent::Text(email)));
265    }
266    if let Some(ref ie) = loc.ie {
267        children.push(tag("IE", &[], TagContent::Text(ie)));
268    }
269    children
270}
271
272/// Build `<cana>` (sugarcane supply) element.
273///
274/// XML schema order (Grupo ZC01):
275/// ```xml
276/// <cana>
277///   <safra>...</safra>
278///   <ref>...</ref>
279///   <forDia dia="N"><qtde>...</qtde></forDia>  <!-- up to 31 -->
280///   <qTotMes>...</qTotMes>
281///   <qTotAnt>...</qTotAnt>
282///   <qTotGer>...</qTotGer>
283///   <deduc><xDed>...</xDed><vDed>...</vDed></deduc>  <!-- up to 10 -->
284///   <vFor>...</vFor>
285///   <vTotDed>...</vTotDed>
286///   <vLiqFor>...</vLiqFor>
287/// </cana>
288/// ```
289pub fn build_cana(cana: &CanaData) -> String {
290    let fc2 = |c: i64| format_cents(c, 2);
291    let fc10 = |c: i64| format_cents(c, 10);
292
293    let mut children = vec![
294        tag("safra", &[], TagContent::Text(&cana.safra)),
295        tag("ref", &[], TagContent::Text(&cana.referencia)),
296    ];
297
298    // forDia entries (up to 31, one per day)
299    for fd in cana.for_dia.iter().take(31) {
300        let dia_str = fd.dia.to_string();
301        children.push(tag(
302            "forDia",
303            &[("dia", &dia_str)],
304            TagContent::Children(vec![tag("qtde", &[], TagContent::Text(&fc10(fd.qtde.0)))]),
305        ));
306    }
307
308    children.push(tag(
309        "qTotMes",
310        &[],
311        TagContent::Text(&fc10(cana.q_tot_mes.0)),
312    ));
313    children.push(tag(
314        "qTotAnt",
315        &[],
316        TagContent::Text(&fc10(cana.q_tot_ant.0)),
317    ));
318    children.push(tag(
319        "qTotGer",
320        &[],
321        TagContent::Text(&fc10(cana.q_tot_ger.0)),
322    ));
323
324    // deduc entries (up to 10)
325    if let Some(ref deducs) = cana.deducoes {
326        for d in deducs.iter().take(10) {
327            children.push(tag(
328                "deduc",
329                &[],
330                TagContent::Children(vec![
331                    tag("xDed", &[], TagContent::Text(&d.x_ded)),
332                    tag("vDed", &[], TagContent::Text(&fc2(d.v_ded.0))),
333                ]),
334            ));
335        }
336    }
337
338    children.push(tag("vFor", &[], TagContent::Text(&fc2(cana.v_for.0))));
339    children.push(tag(
340        "vTotDed",
341        &[],
342        TagContent::Text(&fc2(cana.v_tot_ded.0)),
343    ));
344    children.push(tag(
345        "vLiqFor",
346        &[],
347        TagContent::Text(&fc2(cana.v_liq_for.0)),
348    ));
349
350    tag("cana", &[], TagContent::Children(children))
351}
352
353/// Build `<agropecuario>` element (Grupo ZF01).
354///
355/// Contains either a `<guiaTransito>` or up to 20 `<defensivo>` entries.
356/// Positioned inside `<infNFe>` after `<infRespTec>`.
357pub fn build_agropecuario(data: &AgropecuarioData) -> String {
358    let children = match data {
359        AgropecuarioData::Guia(guia) => {
360            let mut kids = vec![tag("tpGuia", &[], TagContent::Text(&guia.tp_guia))];
361            if let Some(ref uf) = guia.uf_guia {
362                kids.push(tag("UFGuia", &[], TagContent::Text(uf)));
363            }
364            if let Some(ref serie) = guia.serie_guia {
365                kids.push(tag("serieGuia", &[], TagContent::Text(serie)));
366            }
367            kids.push(tag("nGuia", &[], TagContent::Text(&guia.n_guia)));
368            vec![tag("guiaTransito", &[], TagContent::Children(kids))]
369        }
370        AgropecuarioData::Defensivos(defs) => defs
371            .iter()
372            .take(20)
373            .map(|d| {
374                tag(
375                    "defensivo",
376                    &[],
377                    TagContent::Children(vec![
378                        tag("nReceituario", &[], TagContent::Text(&d.n_receituario)),
379                        tag("CPFRespTec", &[], TagContent::Text(&d.cpf_resp_tec)),
380                    ]),
381                )
382            })
383            .collect(),
384    };
385
386    tag("agropecuario", &[], TagContent::Children(children))
387}
388
389/// Build `<gCompraGov>` element (Grupo B31, PL_010+).
390///
391/// Placed inside `<ide>` after `<NFref>` elements.
392pub fn build_compra_gov(data: &CompraGovData) -> String {
393    tag(
394        "gCompraGov",
395        &[],
396        TagContent::Children(vec![
397            tag("tpEnteGov", &[], TagContent::Text(&data.tp_ente_gov)),
398            tag("pRedutor", &[], TagContent::Text(&data.p_redutor)),
399            tag("tpOperGov", &[], TagContent::Text(&data.tp_oper_gov)),
400        ]),
401    )
402}
403
404/// Build `<gPagAntecipado>` element (Grupo B34, PL_010+).
405///
406/// Placed inside `<ide>` after `<gCompraGov>`.
407pub fn build_pag_antecipado(data: &PagAntecipadoData) -> String {
408    let children: Vec<String> = data
409        .ref_nfe
410        .iter()
411        .map(|key| tag("refNFe", &[], TagContent::Text(key)))
412        .collect();
413    tag("gPagAntecipado", &[], TagContent::Children(children))
414}
415
416/// Build `<autXML>` element.
417pub fn build_aut_xml(entry: &AuthorizedXml) -> String {
418    let tid = TaxId::new(&entry.tax_id);
419    let padded = tid.padded();
420    tag(
421        "autXML",
422        &[],
423        TagContent::Children(vec![tag(tid.tag_name(), &[], TagContent::Text(&padded))]),
424    )
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::newtypes::Cents;
431    use crate::xml_builder::IbgeCode;
432
433    #[test]
434    fn build_cana_minimal_without_deducoes() {
435        let cana = CanaData::new(
436            "2025/2026",
437            "03/2026",
438            vec![
439                ForDiaData::new(1, Cents(1000000)),
440                ForDiaData::new(2, Cents(1500000)),
441            ],
442            Cents(2500000), // qTotMes
443            Cents(5000000), // qTotAnt
444            Cents(7500000), // qTotGer
445            Cents(150000),  // vFor = 1500.00
446            Cents(0),       // vTotDed = 0.00
447            Cents(150000),  // vLiqFor = 1500.00
448        );
449
450        let xml = build_cana(&cana);
451
452        assert_eq!(
453            xml,
454            "<cana>\
455                <safra>2025/2026</safra>\
456                <ref>03/2026</ref>\
457                <forDia dia=\"1\"><qtde>10000.0000000000</qtde></forDia>\
458                <forDia dia=\"2\"><qtde>15000.0000000000</qtde></forDia>\
459                <qTotMes>25000.0000000000</qTotMes>\
460                <qTotAnt>50000.0000000000</qTotAnt>\
461                <qTotGer>75000.0000000000</qTotGer>\
462                <vFor>1500.00</vFor>\
463                <vTotDed>0.00</vTotDed>\
464                <vLiqFor>1500.00</vLiqFor>\
465            </cana>"
466        );
467    }
468
469    #[test]
470    fn build_cana_with_deducoes() {
471        let cana = CanaData::new(
472            "2024/2025",
473            "06/2025",
474            vec![ForDiaData::new(15, Cents(2000000))],
475            Cents(2000000),  // qTotMes
476            Cents(10000000), // qTotAnt
477            Cents(12000000), // qTotGer
478            Cents(500000),   // vFor = 5000.00
479            Cents(50000),    // vTotDed = 500.00
480            Cents(450000),   // vLiqFor = 4500.00
481        )
482        .deducoes(vec![
483            DeducData::new("TAXA PRODUCAO", Cents(30000)),
484            DeducData::new("FUNRURAL", Cents(20000)),
485        ]);
486
487        let xml = build_cana(&cana);
488
489        assert!(xml.contains("<safra>2024/2025</safra>"));
490        assert!(xml.contains("<ref>06/2025</ref>"));
491        assert!(xml.contains("<forDia dia=\"15\"><qtde>20000.0000000000</qtde></forDia>"));
492        assert!(xml.contains("<qTotMes>20000.0000000000</qTotMes>"));
493        assert!(xml.contains("<qTotAnt>100000.0000000000</qTotAnt>"));
494        assert!(xml.contains("<qTotGer>120000.0000000000</qTotGer>"));
495        assert!(xml.contains("<deduc><xDed>TAXA PRODUCAO</xDed><vDed>300.00</vDed></deduc>"));
496        assert!(xml.contains("<deduc><xDed>FUNRURAL</xDed><vDed>200.00</vDed></deduc>"));
497        assert!(xml.contains("<vFor>5000.00</vFor>"));
498        assert!(xml.contains("<vTotDed>500.00</vTotDed>"));
499        assert!(xml.contains("<vLiqFor>4500.00</vLiqFor>"));
500
501        // Verify order: deduc comes before vFor
502        let deduc_pos = xml.find("<deduc>").expect("deduc must be present");
503        let vfor_pos = xml.find("<vFor>").expect("vFor must be present");
504        assert!(
505            deduc_pos < vfor_pos,
506            "deduc must come before vFor in the XML"
507        );
508
509        // Verify order: forDia comes before qTotMes
510        let fordia_pos = xml.find("<forDia").expect("forDia must be present");
511        let qtotmes_pos = xml.find("<qTotMes>").expect("qTotMes must be present");
512        assert!(
513            fordia_pos < qtotmes_pos,
514            "forDia must come before qTotMes in the XML"
515        );
516    }
517
518    #[test]
519    fn build_cana_limits_fordia_to_31() {
520        let mut for_dia = Vec::new();
521        for day in 1..=35 {
522            for_dia.push(ForDiaData::new(day, Cents(100000)));
523        }
524
525        let cana = CanaData::new(
526            "2025/2026",
527            "01/2026",
528            for_dia,
529            Cents(0),
530            Cents(0),
531            Cents(0),
532            Cents(0),
533            Cents(0),
534            Cents(0),
535        );
536
537        let xml = build_cana(&cana);
538
539        // Count forDia occurrences — should be capped at 31
540        let count = xml.matches("<forDia").count();
541        assert_eq!(count, 31, "forDia entries must be capped at 31");
542    }
543
544    #[test]
545    fn build_cana_limits_deduc_to_10() {
546        let mut deducs = Vec::new();
547        for i in 1..=15 {
548            deducs.push(DeducData::new(format!("DEDUC {i}"), Cents(1000)));
549        }
550
551        let cana = CanaData::new(
552            "2025/2026",
553            "01/2026",
554            vec![ForDiaData::new(1, Cents(100000))],
555            Cents(0),
556            Cents(0),
557            Cents(0),
558            Cents(0),
559            Cents(0),
560            Cents(0),
561        )
562        .deducoes(deducs);
563
564        let xml = build_cana(&cana);
565
566        // Count deduc occurrences — should be capped at 10
567        let count = xml.matches("<deduc>").count();
568        assert_eq!(count, 10, "deduc entries must be capped at 10");
569    }
570
571    // ── Agropecuário tests ──────────────────────────────────────────────
572
573    #[test]
574    fn build_agropecuario_guia() {
575        let guia = AgropecuarioGuiaData::new("1", "12345")
576            .uf_guia("SP")
577            .serie_guia("A");
578        let data = AgropecuarioData::Guia(guia);
579        let xml = build_agropecuario(&data);
580
581        assert_eq!(
582            xml,
583            "<agropecuario>\
584                <guiaTransito>\
585                    <tpGuia>1</tpGuia>\
586                    <UFGuia>SP</UFGuia>\
587                    <serieGuia>A</serieGuia>\
588                    <nGuia>12345</nGuia>\
589                </guiaTransito>\
590            </agropecuario>"
591        );
592    }
593
594    #[test]
595    fn build_agropecuario_guia_minimal() {
596        let guia = AgropecuarioGuiaData::new("2", "99999");
597        let data = AgropecuarioData::Guia(guia);
598        let xml = build_agropecuario(&data);
599
600        assert_eq!(
601            xml,
602            "<agropecuario>\
603                <guiaTransito>\
604                    <tpGuia>2</tpGuia>\
605                    <nGuia>99999</nGuia>\
606                </guiaTransito>\
607            </agropecuario>"
608        );
609    }
610
611    #[test]
612    fn build_agropecuario_defensivos() {
613        let defs = vec![
614            AgropecuarioDefensivoData::new("REC001", "12345678901"),
615            AgropecuarioDefensivoData::new("REC002", "98765432109"),
616        ];
617        let data = AgropecuarioData::Defensivos(defs);
618        let xml = build_agropecuario(&data);
619
620        assert_eq!(
621            xml,
622            "<agropecuario>\
623                <defensivo>\
624                    <nReceituario>REC001</nReceituario>\
625                    <CPFRespTec>12345678901</CPFRespTec>\
626                </defensivo>\
627                <defensivo>\
628                    <nReceituario>REC002</nReceituario>\
629                    <CPFRespTec>98765432109</CPFRespTec>\
630                </defensivo>\
631            </agropecuario>"
632        );
633    }
634
635    #[test]
636    fn build_agropecuario_defensivos_capped_at_20() {
637        let defs: Vec<AgropecuarioDefensivoData> = (0..25)
638            .map(|i| AgropecuarioDefensivoData::new(format!("REC{i:03}"), "12345678901"))
639            .collect();
640        let data = AgropecuarioData::Defensivos(defs);
641        let xml = build_agropecuario(&data);
642
643        let count = xml.matches("<defensivo>").count();
644        assert_eq!(count, 20, "defensivo entries must be capped at 20");
645    }
646
647    // ── Compra Governamental tests ──────────────────────────────────────
648
649    #[test]
650    fn build_compra_gov_all_fields() {
651        let cg = CompraGovData::new("1", "10.5000", "2");
652        let xml = build_compra_gov(&cg);
653
654        assert_eq!(
655            xml,
656            "<gCompraGov>\
657                <tpEnteGov>1</tpEnteGov>\
658                <pRedutor>10.5000</pRedutor>\
659                <tpOperGov>2</tpOperGov>\
660            </gCompraGov>"
661        );
662    }
663
664    // ── Pagamento Antecipado tests ──────────────────────────────────────
665
666    #[test]
667    fn build_pag_antecipado_single_ref() {
668        let pa = PagAntecipadoData::new(vec![
669            "41260304123456000190550010000001231123456780".to_string(),
670        ]);
671        let xml = build_pag_antecipado(&pa);
672
673        assert_eq!(
674            xml,
675            "<gPagAntecipado>\
676                <refNFe>41260304123456000190550010000001231123456780</refNFe>\
677            </gPagAntecipado>"
678        );
679    }
680
681    #[test]
682    fn build_pag_antecipado_multiple_refs() {
683        let pa = PagAntecipadoData::new(vec![
684            "41260304123456000190550010000001231123456780".to_string(),
685            "41260304123456000190550010000001241123456781".to_string(),
686        ]);
687        let xml = build_pag_antecipado(&pa);
688
689        assert!(xml.contains("<gPagAntecipado>"));
690        assert_eq!(xml.matches("<refNFe>").count(), 2);
691    }
692
693    // ── Retirada / Entrega tests ──────────────────────────────────────
694
695    #[test]
696    fn build_withdrawal_all_fields_cnpj() {
697        let loc = LocationData::new(
698            "12345678000199",
699            "Rua das Flores",
700            "100",
701            "Centro",
702            IbgeCode("3550308".to_string()),
703            "São Paulo",
704            "SP",
705        )
706        .name("Empresa Teste")
707        .complement("Sala 5")
708        .zip_code("01001000")
709        .c_pais("1058")
710        .x_pais("Brasil")
711        .fone("1155551234")
712        .email("teste@empresa.com")
713        .ie("123456789");
714
715        let xml = build_withdrawal(&loc);
716
717        assert_eq!(
718            xml,
719            "<retirada>\
720                <CNPJ>12345678000199</CNPJ>\
721                <xNome>Empresa Teste</xNome>\
722                <xLgr>Rua das Flores</xLgr>\
723                <nro>100</nro>\
724                <xCpl>Sala 5</xCpl>\
725                <xBairro>Centro</xBairro>\
726                <cMun>3550308</cMun>\
727                <xMun>São Paulo</xMun>\
728                <UF>SP</UF>\
729                <CEP>01001000</CEP>\
730                <cPais>1058</cPais>\
731                <xPais>Brasil</xPais>\
732                <fone>1155551234</fone>\
733                <email>teste@empresa.com</email>\
734                <IE>123456789</IE>\
735            </retirada>"
736        );
737    }
738
739    #[test]
740    fn build_delivery_all_fields_cpf() {
741        let loc = LocationData::new(
742            "12345678901",
743            "Av. Brasil",
744            "200",
745            "Bela Vista",
746            IbgeCode("3304557".to_string()),
747            "Rio de Janeiro",
748            "RJ",
749        )
750        .name("João da Silva")
751        .complement("Apto 301")
752        .zip_code("20040020")
753        .c_pais("1058")
754        .x_pais("Brasil")
755        .fone("2199998888")
756        .email("joao@email.com")
757        .ie("ISENTO");
758
759        let xml = build_delivery(&loc);
760
761        assert_eq!(
762            xml,
763            "<entrega>\
764                <CPF>12345678901</CPF>\
765                <xNome>João da Silva</xNome>\
766                <xLgr>Av. Brasil</xLgr>\
767                <nro>200</nro>\
768                <xCpl>Apto 301</xCpl>\
769                <xBairro>Bela Vista</xBairro>\
770                <cMun>3304557</cMun>\
771                <xMun>Rio de Janeiro</xMun>\
772                <UF>RJ</UF>\
773                <CEP>20040020</CEP>\
774                <cPais>1058</cPais>\
775                <xPais>Brasil</xPais>\
776                <fone>2199998888</fone>\
777                <email>joao@email.com</email>\
778                <IE>ISENTO</IE>\
779            </entrega>"
780        );
781    }
782
783    #[test]
784    fn build_withdrawal_minimal_only_required() {
785        let loc = LocationData::new(
786            "12345678000199",
787            "Rua A",
788            "1",
789            "Centro",
790            IbgeCode("3550308".to_string()),
791            "São Paulo",
792            "SP",
793        );
794
795        let xml = build_withdrawal(&loc);
796
797        assert_eq!(
798            xml,
799            "<retirada>\
800                <CNPJ>12345678000199</CNPJ>\
801                <xLgr>Rua A</xLgr>\
802                <nro>1</nro>\
803                <xBairro>Centro</xBairro>\
804                <cMun>3550308</cMun>\
805                <xMun>São Paulo</xMun>\
806                <UF>SP</UF>\
807            </retirada>"
808        );
809    }
810
811    #[test]
812    fn build_delivery_partial_new_fields() {
813        let loc = LocationData::new(
814            "98765432000111",
815            "Rua B",
816            "50",
817            "Jardim",
818            IbgeCode("4106902".to_string()),
819            "Curitiba",
820            "PR",
821        )
822        .fone("4133334444")
823        .email("contato@loja.com");
824
825        let xml = build_delivery(&loc);
826
827        // Verifica que fone e email aparecem mas cPais, xPais e IE não
828        assert_eq!(
829            xml,
830            "<entrega>\
831                <CNPJ>98765432000111</CNPJ>\
832                <xLgr>Rua B</xLgr>\
833                <nro>50</nro>\
834                <xBairro>Jardim</xBairro>\
835                <cMun>4106902</cMun>\
836                <xMun>Curitiba</xMun>\
837                <UF>PR</UF>\
838                <fone>4133334444</fone>\
839                <email>contato@loja.com</email>\
840            </entrega>"
841        );
842    }
843
844    #[test]
845    fn build_withdrawal_tag_order_matches_php() {
846        // Verifica que a ordem das tags confere com o PHP sped-nfe
847        let loc = LocationData::new(
848            "12345678000199",
849            "Rua X",
850            "10",
851            "Bairro Y",
852            IbgeCode("1100015".to_string()),
853            "Porto Velho",
854            "RO",
855        )
856        .name("Empresa")
857        .complement("Loja 1")
858        .zip_code("76801000")
859        .c_pais("1058")
860        .x_pais("Brasil")
861        .fone("6932221111")
862        .email("a@b.com")
863        .ie("1234");
864
865        let xml = build_withdrawal(&loc);
866
867        // Ordem PHP: CNPJ, xNome, xLgr, nro, xCpl, xBairro, cMun, xMun, UF, CEP, cPais, xPais, fone, email, IE
868        let tags_in_order = [
869            "<CNPJ>",
870            "<xNome>",
871            "<xLgr>",
872            "<nro>",
873            "<xCpl>",
874            "<xBairro>",
875            "<cMun>",
876            "<xMun>",
877            "<UF>",
878            "<CEP>",
879            "<cPais>",
880            "<xPais>",
881            "<fone>",
882            "<email>",
883            "<IE>",
884        ];
885        let mut last_pos = 0;
886        for tag_name in &tags_in_order {
887            let pos = xml
888                .find(tag_name)
889                .unwrap_or_else(|| panic!("tag {tag_name} não encontrada no XML"));
890            assert!(
891                pos >= last_pos,
892                "tag {tag_name} está fora de ordem (pos {pos} < {last_pos})"
893            );
894            last_pos = pos;
895        }
896    }
897}