Skip to main content

fiscal_core/xml_builder/
builder.rs

1//! Typestate invoice builder for NF-e / NFC-e XML generation.
2//!
3//! ```text
4//! InvoiceBuilder::new(issuer, env, model)   // Draft
5//!     .series(1)
6//!     .invoice_number(42)
7//!     .add_item(item)
8//!     .recipient(recipient)
9//!     .payments(vec![payment])
10//!     .build()?                              // Built
11//!     .sign_with(|xml| sign(xml))?           // Signed
12//!     .signed_xml()                          // &str
13//! ```
14//!
15//! The typestate pattern ensures at compile time that `xml()` / `access_key()`
16//! are only available after a successful `build()`, and `signed_xml()` is only
17//! available after a successful `sign_with()`.
18
19use std::marker::PhantomData;
20
21use chrono::{DateTime, FixedOffset};
22
23use crate::FiscalError;
24use crate::newtypes::Cents;
25use crate::types::*;
26
27// ── Typestate markers ────────────────────────────────────────────────────────
28
29/// Marker: invoice is being assembled (setters available, no XML yet).
30pub struct Draft;
31
32/// Marker: invoice has been built (XML and access key available, no setters).
33pub struct Built;
34
35/// Marker: invoice has been signed (signed XML available).
36pub struct Signed;
37
38// ── Builder ──────────────────────────────────────────────────────────────────
39
40/// Typestate builder for NF-e / NFC-e XML documents.
41///
42/// In the [`Draft`] state all setters are available.
43/// Calling [`build()`](InvoiceBuilder::build) validates the data and
44/// transitions to [`Built`], which exposes [`xml()`](InvoiceBuilder::xml)
45/// and [`access_key()`](InvoiceBuilder::access_key).
46/// Calling [`sign_with()`](InvoiceBuilder::sign_with) on `Built` transitions
47/// to [`Signed`], which exposes [`signed_xml()`](InvoiceBuilder::signed_xml).
48pub struct InvoiceBuilder<State = Draft> {
49    // Required from construction
50    issuer: IssuerData,
51    environment: SefazEnvironment,
52    model: InvoiceModel,
53
54    // Defaults provided, overridable
55    series: u32,
56    invoice_number: u32,
57    emission_type: EmissionType,
58    issued_at: DateTime<FixedOffset>,
59    operation_nature: String,
60
61    // Accumulated during Draft
62    items: Vec<InvoiceItemData>,
63    recipient: Option<RecipientData>,
64    payments: Vec<PaymentData>,
65    change_amount: Option<Cents>,
66    payment_card_details: Option<Vec<PaymentCardDetail>>,
67    contingency: Option<ContingencyData>,
68    exit_at: Option<DateTime<FixedOffset>>,
69
70    // IDE overrides
71    operation_type: Option<u8>,
72    purpose_code: Option<u8>,
73    destination_indicator: Option<String>,
74    intermediary_indicator: Option<String>,
75    emission_process: Option<String>,
76    consumer_type: Option<String>,
77    buyer_presence: Option<String>,
78    print_format: Option<String>,
79    ver_proc: Option<String>,
80    references: Option<Vec<ReferenceDoc>>,
81
82    // Optional groups
83    transport: Option<TransportData>,
84    billing: Option<BillingData>,
85    withdrawal: Option<LocationData>,
86    delivery: Option<LocationData>,
87    authorized_xml: Option<Vec<AuthorizedXml>>,
88    additional_info: Option<AdditionalInfo>,
89    intermediary: Option<IntermediaryData>,
90    ret_trib: Option<RetTribData>,
91    tech_responsible: Option<TechResponsibleData>,
92    purchase: Option<PurchaseData>,
93    export: Option<ExportData>,
94    issqn_tot: Option<IssqnTotData>,
95    cana: Option<CanaData>,
96    agropecuario: Option<AgropecuarioData>,
97    compra_gov: Option<CompraGovData>,
98    pag_antecipado: Option<PagAntecipadoData>,
99    is_tot: Option<crate::tax_ibs_cbs::IsTotData>,
100    ibs_cbs_tot: Option<crate::tax_ibs_cbs::IbsCbsTotData>,
101
102    // Present only after build
103    result_xml: Option<String>,
104    result_access_key: Option<String>,
105
106    // Present only after sign
107    result_signed_xml: Option<String>,
108
109    _state: PhantomData<State>,
110}
111
112// ── Draft methods (setters + build) ──────────────────────────────────────────
113
114impl InvoiceBuilder<Draft> {
115    /// Create a new builder in the [`Draft`] state.
116    ///
117    /// The three arguments are required; everything else has sensible defaults
118    /// or is optional.
119    pub fn new(issuer: IssuerData, environment: SefazEnvironment, model: InvoiceModel) -> Self {
120        let now = chrono::Utc::now()
121            .with_timezone(&FixedOffset::west_opt(3 * 3600).expect("valid offset"));
122
123        Self {
124            issuer,
125            environment,
126            model,
127            series: 1,
128            invoice_number: 1,
129            emission_type: EmissionType::Normal,
130            issued_at: now,
131            operation_nature: "VENDA".to_string(),
132            items: Vec::new(),
133            recipient: None,
134            payments: Vec::new(),
135            change_amount: None,
136            payment_card_details: None,
137            contingency: None,
138            exit_at: None,
139            operation_type: None,
140            purpose_code: None,
141            destination_indicator: None,
142            intermediary_indicator: None,
143            emission_process: None,
144            consumer_type: None,
145            buyer_presence: None,
146            print_format: None,
147            ver_proc: None,
148            references: None,
149            transport: None,
150            billing: None,
151            withdrawal: None,
152            delivery: None,
153            authorized_xml: None,
154            additional_info: None,
155            intermediary: None,
156            ret_trib: None,
157            tech_responsible: None,
158            purchase: None,
159            export: None,
160            issqn_tot: None,
161            cana: None,
162            agropecuario: None,
163            compra_gov: None,
164            pag_antecipado: None,
165            is_tot: None,
166            ibs_cbs_tot: None,
167            result_xml: None,
168            result_access_key: None,
169            result_signed_xml: None,
170            _state: PhantomData,
171        }
172    }
173
174    // ── Chainable setters ────────────────────────────────────────────────
175
176    /// Set the invoice series (default: 1).
177    pub fn series(mut self, s: u32) -> Self {
178        self.series = s;
179        self
180    }
181
182    /// Set the invoice number (default: 1).
183    pub fn invoice_number(mut self, n: u32) -> Self {
184        self.invoice_number = n;
185        self
186    }
187
188    /// Set the emission type (default: [`EmissionType::Normal`]).
189    pub fn emission_type(mut self, et: EmissionType) -> Self {
190        self.emission_type = et;
191        self
192    }
193
194    /// Set the emission date/time (default: now in UTC-3).
195    pub fn issued_at(mut self, dt: DateTime<FixedOffset>) -> Self {
196        self.issued_at = dt;
197        self
198    }
199
200    /// Set the operation nature (default: `"VENDA"`).
201    pub fn operation_nature(mut self, n: impl Into<String>) -> Self {
202        self.operation_nature = n.into();
203        self
204    }
205
206    /// Add one item to the invoice.
207    pub fn add_item(mut self, item: InvoiceItemData) -> Self {
208        self.items.push(item);
209        self
210    }
211
212    /// Set all items at once (replaces any previously added items).
213    pub fn items(mut self, items: Vec<InvoiceItemData>) -> Self {
214        self.items = items;
215        self
216    }
217
218    /// Set the recipient (optional for NFC-e under R$200).
219    pub fn recipient(mut self, r: RecipientData) -> Self {
220        self.recipient = Some(r);
221        self
222    }
223
224    /// Set the payment list.
225    pub fn payments(mut self, p: Vec<PaymentData>) -> Self {
226        self.payments = p;
227        self
228    }
229
230    /// Set the change amount (vTroco).
231    pub fn change_amount(mut self, c: Cents) -> Self {
232        self.change_amount = Some(c);
233        self
234    }
235
236    /// Set card payment details.
237    pub fn payment_card_details(mut self, d: Vec<PaymentCardDetail>) -> Self {
238        self.payment_card_details = Some(d);
239        self
240    }
241
242    /// Set contingency data.
243    pub fn contingency(mut self, c: ContingencyData) -> Self {
244        self.contingency = Some(c);
245        self
246    }
247
248    /// Set the exit/departure date/time (dhSaiEnt, model 55 only).
249    pub fn exit_at(mut self, dt: DateTime<FixedOffset>) -> Self {
250        self.exit_at = Some(dt);
251        self
252    }
253
254    /// Override the operation type (tpNF, default: 1).
255    pub fn operation_type(mut self, v: u8) -> Self {
256        self.operation_type = Some(v);
257        self
258    }
259
260    /// Override the invoice purpose code (finNFe, default: 1).
261    pub fn purpose_code(mut self, v: u8) -> Self {
262        self.purpose_code = Some(v);
263        self
264    }
265
266    /// Set the intermediary indicator (indIntermed).
267    pub fn intermediary_indicator(mut self, v: impl Into<String>) -> Self {
268        self.intermediary_indicator = Some(v.into());
269        self
270    }
271
272    /// Set the emission process (procEmi).
273    pub fn emission_process(mut self, v: impl Into<String>) -> Self {
274        self.emission_process = Some(v.into());
275        self
276    }
277
278    /// Set the consumer type (indFinal).
279    pub fn consumer_type(mut self, v: impl Into<String>) -> Self {
280        self.consumer_type = Some(v.into());
281        self
282    }
283
284    /// Set the buyer presence indicator (indPres).
285    pub fn buyer_presence(mut self, v: impl Into<String>) -> Self {
286        self.buyer_presence = Some(v.into());
287        self
288    }
289
290    /// Set the DANFE print format (tpImp).
291    pub fn print_format(mut self, v: impl Into<String>) -> Self {
292        self.print_format = Some(v.into());
293        self
294    }
295
296    /// Set the destination indicator (idDest): "1" internal, "2" interstate, "3" export.
297    pub fn destination_indicator(mut self, v: impl Into<String>) -> Self {
298        self.destination_indicator = Some(v.into());
299        self
300    }
301
302    /// Set the application version (verProc).
303    pub fn ver_proc(mut self, v: impl Into<String>) -> Self {
304        self.ver_proc = Some(v.into());
305        self
306    }
307
308    /// Set referenced documents (NFref).
309    pub fn references(mut self, refs: Vec<ReferenceDoc>) -> Self {
310        self.references = Some(refs);
311        self
312    }
313
314    /// Set transport data.
315    pub fn transport(mut self, t: TransportData) -> Self {
316        self.transport = Some(t);
317        self
318    }
319
320    /// Set billing data (cobr).
321    pub fn billing(mut self, b: BillingData) -> Self {
322        self.billing = Some(b);
323        self
324    }
325
326    /// Set the withdrawal/pickup location (retirada).
327    pub fn withdrawal(mut self, w: LocationData) -> Self {
328        self.withdrawal = Some(w);
329        self
330    }
331
332    /// Set the delivery location (entrega).
333    pub fn delivery(mut self, d: LocationData) -> Self {
334        self.delivery = Some(d);
335        self
336    }
337
338    /// Set authorized XML downloaders (autXML).
339    pub fn authorized_xml(mut self, a: Vec<AuthorizedXml>) -> Self {
340        self.authorized_xml = Some(a);
341        self
342    }
343
344    /// Set additional info (infAdic).
345    pub fn additional_info(mut self, a: AdditionalInfo) -> Self {
346        self.additional_info = Some(a);
347        self
348    }
349
350    /// Set intermediary data (infIntermed).
351    pub fn intermediary(mut self, i: IntermediaryData) -> Self {
352        self.intermediary = Some(i);
353        self
354    }
355
356    /// Set retained taxes (retTrib).
357    pub fn ret_trib(mut self, r: RetTribData) -> Self {
358        self.ret_trib = Some(r);
359        self
360    }
361
362    /// Set tech responsible (infRespTec).
363    pub fn tech_responsible(mut self, t: TechResponsibleData) -> Self {
364        self.tech_responsible = Some(t);
365        self
366    }
367
368    /// Set purchase data (compra).
369    pub fn purchase(mut self, p: PurchaseData) -> Self {
370        self.purchase = Some(p);
371        self
372    }
373
374    /// Set export data (exporta).
375    pub fn export(mut self, e: ExportData) -> Self {
376        self.export = Some(e);
377        self
378    }
379
380    /// Set ISSQN total data (ISSQNtot).
381    pub fn issqn_tot(mut self, t: IssqnTotData) -> Self {
382        self.issqn_tot = Some(t);
383        self
384    }
385
386    /// Set sugarcane supply data (cana).
387    pub fn cana(mut self, c: CanaData) -> Self {
388        self.cana = Some(c);
389        self
390    }
391
392    /// Set agropecuário data (guia de trânsito or defensivos).
393    pub fn agropecuario(mut self, a: AgropecuarioData) -> Self {
394        self.agropecuario = Some(a);
395        self
396    }
397
398    /// Set compra governamental data (gCompraGov, PL_010+).
399    pub fn compra_gov(mut self, c: CompraGovData) -> Self {
400        self.compra_gov = Some(c);
401        self
402    }
403
404    /// Set pagamento antecipado data (gPagAntecipado, PL_010+).
405    pub fn pag_antecipado(mut self, p: PagAntecipadoData) -> Self {
406        self.pag_antecipado = Some(p);
407        self
408    }
409
410    /// Set IS (Imposto Seletivo) total data.
411    pub fn is_tot(mut self, t: crate::tax_ibs_cbs::IsTotData) -> Self {
412        self.is_tot = Some(t);
413        self
414    }
415
416    /// Set IBS/CBS total data.
417    pub fn ibs_cbs_tot(mut self, t: crate::tax_ibs_cbs::IbsCbsTotData) -> Self {
418        self.ibs_cbs_tot = Some(t);
419        self
420    }
421
422    /// Validate and build the XML, transitioning to [`Built`].
423    ///
424    /// # Errors
425    ///
426    /// Returns [`FiscalError`] if:
427    /// - The issuer state code is unknown
428    /// - Tax data is invalid
429    pub fn build(self) -> Result<InvoiceBuilder<Built>, FiscalError> {
430        let data = InvoiceBuildData {
431            model: self.model,
432            series: self.series,
433            number: self.invoice_number,
434            emission_type: self.emission_type,
435            environment: self.environment,
436            issued_at: self.issued_at,
437            operation_nature: self.operation_nature,
438            issuer: self.issuer,
439            recipient: self.recipient,
440            items: self.items,
441            payments: self.payments,
442            change_amount: self.change_amount,
443            payment_card_details: self.payment_card_details,
444            contingency: self.contingency,
445            exit_at: self.exit_at,
446            operation_type: self.operation_type,
447            purpose_code: self.purpose_code,
448            destination_indicator: self.destination_indicator,
449            intermediary_indicator: self.intermediary_indicator,
450            emission_process: self.emission_process,
451            consumer_type: self.consumer_type,
452            buyer_presence: self.buyer_presence,
453            print_format: self.print_format,
454            ver_proc: self.ver_proc,
455            references: self.references,
456            transport: self.transport,
457            billing: self.billing,
458            withdrawal: self.withdrawal,
459            delivery: self.delivery,
460            authorized_xml: self.authorized_xml,
461            additional_info: self.additional_info,
462            intermediary: self.intermediary,
463            ret_trib: self.ret_trib,
464            tech_responsible: self.tech_responsible,
465            purchase: self.purchase,
466            export: self.export,
467            issqn_tot: self.issqn_tot,
468            cana: self.cana,
469            agropecuario: self.agropecuario,
470            compra_gov: self.compra_gov,
471            pag_antecipado: self.pag_antecipado,
472            is_tot: self.is_tot,
473            ibs_cbs_tot: self.ibs_cbs_tot,
474        };
475
476        let result = super::generate_xml(&data)?;
477
478        Ok(InvoiceBuilder {
479            issuer: data.issuer,
480            environment: data.environment,
481            model: data.model,
482            series: data.series,
483            invoice_number: data.number,
484            emission_type: data.emission_type,
485            issued_at: data.issued_at,
486            operation_nature: data.operation_nature,
487            items: data.items,
488            recipient: data.recipient,
489            payments: data.payments,
490            change_amount: data.change_amount,
491            payment_card_details: data.payment_card_details,
492            contingency: data.contingency,
493            exit_at: data.exit_at,
494            operation_type: data.operation_type,
495            purpose_code: data.purpose_code,
496            destination_indicator: data.destination_indicator,
497            intermediary_indicator: data.intermediary_indicator,
498            emission_process: data.emission_process,
499            consumer_type: data.consumer_type,
500            buyer_presence: data.buyer_presence,
501            print_format: data.print_format,
502            ver_proc: data.ver_proc,
503            references: data.references,
504            transport: data.transport,
505            billing: data.billing,
506            withdrawal: data.withdrawal,
507            delivery: data.delivery,
508            authorized_xml: data.authorized_xml,
509            additional_info: data.additional_info,
510            intermediary: data.intermediary,
511            ret_trib: data.ret_trib,
512            tech_responsible: data.tech_responsible,
513            purchase: data.purchase,
514            export: data.export,
515            issqn_tot: data.issqn_tot,
516            cana: data.cana,
517            agropecuario: data.agropecuario,
518            compra_gov: data.compra_gov,
519            pag_antecipado: data.pag_antecipado,
520            is_tot: data.is_tot,
521            ibs_cbs_tot: data.ibs_cbs_tot,
522            result_xml: Some(result.xml),
523            result_access_key: Some(result.access_key),
524            result_signed_xml: None,
525            _state: PhantomData,
526        })
527    }
528}
529
530// ── Built methods (accessors) ────────────────────────────────────────────────
531
532impl InvoiceBuilder<Built> {
533    /// The unsigned XML string.
534    pub fn xml(&self) -> &str {
535        self.result_xml
536            .as_deref()
537            .expect("Built state always has XML")
538    }
539
540    /// The 44-digit access key.
541    pub fn access_key(&self) -> &str {
542        self.result_access_key
543            .as_deref()
544            .expect("Built state always has access key")
545    }
546
547    /// Sign the XML using the provided signing function.
548    ///
549    /// The signing function receives the unsigned XML and must return
550    /// the signed XML or an error. This keeps `fiscal-core` independent
551    /// of the crypto implementation.
552    ///
553    /// # Examples
554    ///
555    /// ```
556    /// # use fiscal_core::xml_builder::{InvoiceBuilder, Draft, Built, Signed};
557    /// # use fiscal_core::FiscalError;
558    /// // Assuming `builder` is an InvoiceBuilder<Built>:
559    /// # fn example(builder: InvoiceBuilder<Built>) -> Result<(), FiscalError> {
560    /// let signed = builder.sign_with(|xml| {
561    ///     // In real code, call fiscal_crypto::certificate::sign_xml() here.
562    ///     Ok(format!("{xml}<Signature/>"))
563    /// })?;
564    /// assert!(signed.signed_xml().contains("<Signature/>"));
565    /// # Ok(())
566    /// # }
567    /// ```
568    ///
569    /// # Errors
570    ///
571    /// Returns [`FiscalError`] if the signing function returns an error.
572    pub fn sign_with<F>(self, signer: F) -> Result<InvoiceBuilder<Signed>, FiscalError>
573    where
574        F: FnOnce(&str) -> Result<String, FiscalError>,
575    {
576        let unsigned_xml = self
577            .result_xml
578            .as_deref()
579            .expect("Built state always has XML");
580
581        let signed_xml = signer(unsigned_xml)?;
582
583        Ok(InvoiceBuilder {
584            issuer: self.issuer,
585            environment: self.environment,
586            model: self.model,
587            series: self.series,
588            invoice_number: self.invoice_number,
589            emission_type: self.emission_type,
590            issued_at: self.issued_at,
591            operation_nature: self.operation_nature,
592            items: self.items,
593            recipient: self.recipient,
594            payments: self.payments,
595            change_amount: self.change_amount,
596            payment_card_details: self.payment_card_details,
597            contingency: self.contingency,
598            exit_at: self.exit_at,
599            operation_type: self.operation_type,
600            purpose_code: self.purpose_code,
601            destination_indicator: self.destination_indicator,
602            intermediary_indicator: self.intermediary_indicator,
603            emission_process: self.emission_process,
604            consumer_type: self.consumer_type,
605            buyer_presence: self.buyer_presence,
606            print_format: self.print_format,
607            ver_proc: self.ver_proc,
608            references: self.references,
609            transport: self.transport,
610            billing: self.billing,
611            withdrawal: self.withdrawal,
612            delivery: self.delivery,
613            authorized_xml: self.authorized_xml,
614            additional_info: self.additional_info,
615            intermediary: self.intermediary,
616            ret_trib: self.ret_trib,
617            tech_responsible: self.tech_responsible,
618            purchase: self.purchase,
619            export: self.export,
620            issqn_tot: self.issqn_tot,
621            cana: self.cana,
622            agropecuario: self.agropecuario,
623            compra_gov: self.compra_gov,
624            pag_antecipado: self.pag_antecipado,
625            is_tot: self.is_tot,
626            ibs_cbs_tot: self.ibs_cbs_tot,
627            result_xml: self.result_xml,
628            result_access_key: self.result_access_key,
629            result_signed_xml: Some(signed_xml),
630            _state: PhantomData,
631        })
632    }
633}
634
635// ── Signed methods (accessors) ──────────────────────────────────────────────
636
637impl InvoiceBuilder<Signed> {
638    /// The signed XML string (includes `<Signature>` element).
639    pub fn signed_xml(&self) -> &str {
640        self.result_signed_xml
641            .as_deref()
642            .expect("Signed state always has signed XML")
643    }
644
645    /// The 44-digit access key.
646    pub fn access_key(&self) -> &str {
647        self.result_access_key
648            .as_deref()
649            .expect("Signed state always has access key")
650    }
651
652    /// The unsigned XML (before signing).
653    pub fn unsigned_xml(&self) -> &str {
654        self.result_xml
655            .as_deref()
656            .expect("Signed state always has unsigned XML")
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::newtypes::{Cents, IbgeCode, Rate};
664    use crate::types::{
665        InvoiceItemData, InvoiceModel, IssuerData, PaymentData, SefazEnvironment, TaxRegime,
666    };
667
668    /// Standard Brazilian timezone offset (UTC-3).
669    fn br_offset() -> chrono::FixedOffset {
670        chrono::FixedOffset::west_opt(3 * 3600).unwrap()
671    }
672
673    /// Build a minimal InvoiceBuilder in Draft state.
674    fn sample_builder() -> InvoiceBuilder<Draft> {
675        let issuer = IssuerData::new(
676            "12345678000199",
677            "123456789",
678            "Test Company",
679            TaxRegime::SimplesNacional,
680            "SP",
681            IbgeCode("3550308".to_string()),
682            "Sao Paulo",
683            "Av Paulista",
684            "1000",
685            "Bela Vista",
686            "01310100",
687        )
688        .trade_name("Test");
689
690        let item = InvoiceItemData::new(
691            1,
692            "1",
693            "Product A",
694            "84715010",
695            "5102",
696            "UN",
697            2.0,
698            Cents(1000),
699            Cents(2000),
700            "102",
701            Rate(0),
702            Cents(0),
703            "99",
704            "99",
705        );
706
707        let payment = PaymentData::new("01", Cents(2000));
708
709        let offset = br_offset();
710        let issued_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
711            .unwrap()
712            .and_hms_opt(10, 30, 0)
713            .unwrap()
714            .and_local_timezone(offset)
715            .unwrap();
716
717        InvoiceBuilder::new(issuer, SefazEnvironment::Homologation, InvoiceModel::Nfce)
718            .series(1)
719            .invoice_number(1)
720            .issued_at(issued_at)
721            .add_item(item)
722            .payments(vec![payment])
723    }
724
725    /// Build a minimal InvoiceBuilder<Built>.
726    fn built_builder() -> InvoiceBuilder<Built> {
727        sample_builder().build().expect("build should succeed")
728    }
729
730    #[test]
731    fn sign_with_identity_fn() {
732        let built = built_builder();
733        let original_xml = built.xml().to_string();
734
735        let signed = built
736            .sign_with(|xml| Ok(xml.to_string()))
737            .expect("identity signer should not fail");
738
739        assert_eq!(signed.signed_xml(), original_xml);
740    }
741
742    #[test]
743    fn sign_with_failing_fn() {
744        let built = built_builder();
745
746        let result =
747            built.sign_with(|_xml| Err(FiscalError::Certificate("test signing failure".into())));
748
749        let err = match result {
750            Err(e) => e,
751            Ok(_) => panic!("expected sign_with to return Err"),
752        };
753        assert_eq!(err, FiscalError::Certificate("test signing failure".into()),);
754    }
755
756    #[test]
757    fn signed_accessors() {
758        let built = built_builder();
759        let original_xml = built.xml().to_string();
760        let original_key = built.access_key().to_string();
761
762        let signed = built
763            .sign_with(|xml| Ok(format!("{xml}<Signature/>")))
764            .expect("signer should succeed");
765
766        assert_eq!(signed.signed_xml(), format!("{original_xml}<Signature/>"),);
767        assert_eq!(signed.access_key(), original_key);
768        assert_eq!(signed.unsigned_xml(), original_xml);
769    }
770
771    #[test]
772    fn built_still_works() {
773        let built = built_builder();
774
775        // Verify Built accessors are available and correct.
776        let xml = built.xml();
777        assert!(xml.contains("<NFe"));
778        assert!(xml.contains("</NFe>"));
779        assert!(xml.contains("<infNFe"));
780
781        let key = built.access_key();
782        assert_eq!(key.len(), 44);
783        assert!(key.chars().all(|c| c.is_ascii_digit()));
784    }
785
786    /// Build an NF-e (model 55) builder for testing dhSaiEnt.
787    fn nfe_builder() -> InvoiceBuilder<Draft> {
788        let issuer = IssuerData::new(
789            "12345678000199",
790            "123456789",
791            "Test Company",
792            TaxRegime::SimplesNacional,
793            "SP",
794            IbgeCode("3550308".to_string()),
795            "Sao Paulo",
796            "Av Paulista",
797            "1000",
798            "Bela Vista",
799            "01310100",
800        )
801        .trade_name("Test");
802
803        let item = InvoiceItemData::new(
804            1,
805            "1",
806            "Product A",
807            "84715010",
808            "5102",
809            "UN",
810            2.0,
811            Cents(1000),
812            Cents(2000),
813            "102",
814            Rate(0),
815            Cents(0),
816            "99",
817            "99",
818        );
819
820        let payment = PaymentData::new("01", Cents(2000));
821
822        let offset = br_offset();
823        let issued_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
824            .unwrap()
825            .and_hms_opt(10, 30, 0)
826            .unwrap()
827            .and_local_timezone(offset)
828            .unwrap();
829
830        InvoiceBuilder::new(issuer, SefazEnvironment::Homologation, InvoiceModel::Nfe)
831            .series(1)
832            .invoice_number(1)
833            .issued_at(issued_at)
834            .add_item(item)
835            .payments(vec![payment])
836    }
837
838    #[test]
839    fn dh_sai_ent_emitted_for_model_55() {
840        let offset = br_offset();
841        let exit = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
842            .unwrap()
843            .and_hms_opt(14, 0, 0)
844            .unwrap()
845            .and_local_timezone(offset)
846            .unwrap();
847
848        let built = nfe_builder()
849            .exit_at(exit)
850            .build()
851            .expect("build should succeed");
852
853        let xml = built.xml();
854        assert!(
855            xml.contains("<dhSaiEnt>2026-01-15T14:00:00-03:00</dhSaiEnt>"),
856            "NF-e (model 55) with exit_at must emit <dhSaiEnt>, got:\n{xml}"
857        );
858        // Verify ordering: dhSaiEnt must come after dhEmi and before tpNF
859        let emi_pos = xml.find("<dhEmi>").expect("dhEmi must be present");
860        let sai_pos = xml.find("<dhSaiEnt>").expect("dhSaiEnt must be present");
861        let tp_nf_pos = xml.find("<tpNF>").expect("tpNF must be present");
862        assert!(
863            emi_pos < sai_pos && sai_pos < tp_nf_pos,
864            "dhSaiEnt must come after dhEmi and before tpNF"
865        );
866    }
867
868    #[test]
869    fn dh_sai_ent_omitted_for_model_65() {
870        let offset = br_offset();
871        let exit = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
872            .unwrap()
873            .and_hms_opt(14, 0, 0)
874            .unwrap()
875            .and_local_timezone(offset)
876            .unwrap();
877
878        let built = sample_builder()
879            .exit_at(exit)
880            .build()
881            .expect("build should succeed");
882
883        let xml = built.xml();
884        assert!(
885            !xml.contains("<dhSaiEnt>"),
886            "NFC-e (model 65) must NOT emit <dhSaiEnt>, got:\n{xml}"
887        );
888    }
889
890    #[test]
891    fn dh_sai_ent_omitted_when_not_set() {
892        let built = nfe_builder().build().expect("build should succeed");
893
894        let xml = built.xml();
895        assert!(
896            !xml.contains("<dhSaiEnt>"),
897            "NF-e without exit_at must NOT emit <dhSaiEnt>"
898        );
899    }
900
901    #[test]
902    fn dh_cont_and_x_just_emitted_in_contingency() {
903        use crate::types::{ContingencyData, ContingencyType};
904
905        let offset = br_offset();
906        let cont_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
907            .unwrap()
908            .and_hms_opt(9, 0, 0)
909            .unwrap()
910            .and_local_timezone(offset)
911            .unwrap();
912
913        let contingency = ContingencyData::new(
914            ContingencyType::SvcAn,
915            "SEFAZ fora do ar para manutencao programada",
916            cont_at,
917        );
918
919        let built = nfe_builder()
920            .contingency(contingency)
921            .build()
922            .expect("build should succeed");
923
924        let xml = built.xml();
925        assert!(
926            xml.contains("<dhCont>2026-01-15T09:00:00-03:00</dhCont>"),
927            "Contingency must emit <dhCont>, got:\n{xml}"
928        );
929        assert!(
930            xml.contains("<xJust>SEFAZ fora do ar para manutencao programada</xJust>"),
931            "Contingency must emit <xJust>, got:\n{xml}"
932        );
933        // Verify ordering: dhCont/xJust must come after verProc
934        let ver_proc_pos = xml.find("<verProc>").expect("verProc must be present");
935        let dh_cont_pos = xml.find("<dhCont>").expect("dhCont must be present");
936        let x_just_pos = xml.find("<xJust>").expect("xJust must be present");
937        assert!(
938            ver_proc_pos < dh_cont_pos && dh_cont_pos < x_just_pos,
939            "dhCont must come after verProc, xJust must come after dhCont"
940        );
941    }
942
943    #[test]
944    fn dh_cont_omitted_without_contingency() {
945        let built = nfe_builder().build().expect("build should succeed");
946
947        let xml = built.xml();
948        assert!(
949            !xml.contains("<dhCont>"),
950            "Without contingency, <dhCont> must NOT be present"
951        );
952        assert!(
953            !xml.contains("<xJust>"),
954            "Without contingency, <xJust> must NOT be present"
955        );
956    }
957}