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