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