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
69    // IDE overrides
70    operation_type: Option<u8>,
71    purpose_code: Option<u8>,
72    intermediary_indicator: Option<String>,
73    emission_process: Option<String>,
74    consumer_type: Option<String>,
75    buyer_presence: Option<String>,
76    print_format: Option<String>,
77    references: Option<Vec<ReferenceDoc>>,
78
79    // Optional groups
80    transport: Option<TransportData>,
81    billing: Option<BillingData>,
82    withdrawal: Option<LocationData>,
83    delivery: Option<LocationData>,
84    authorized_xml: Option<Vec<AuthorizedXml>>,
85    additional_info: Option<AdditionalInfo>,
86    intermediary: Option<IntermediaryData>,
87    ret_trib: Option<RetTribData>,
88    tech_responsible: Option<TechResponsibleData>,
89    purchase: Option<PurchaseData>,
90    export: Option<ExportData>,
91
92    // Present only after build
93    result_xml: Option<String>,
94    result_access_key: Option<String>,
95
96    // Present only after sign
97    result_signed_xml: Option<String>,
98
99    _state: PhantomData<State>,
100}
101
102// ── Draft methods (setters + build) ──────────────────────────────────────────
103
104impl InvoiceBuilder<Draft> {
105    /// Create a new builder in the [`Draft`] state.
106    ///
107    /// The three arguments are required; everything else has sensible defaults
108    /// or is optional.
109    pub fn new(issuer: IssuerData, environment: SefazEnvironment, model: InvoiceModel) -> Self {
110        let now = chrono::Utc::now()
111            .with_timezone(&FixedOffset::west_opt(3 * 3600).expect("valid offset"));
112
113        Self {
114            issuer,
115            environment,
116            model,
117            series: 1,
118            invoice_number: 1,
119            emission_type: EmissionType::Normal,
120            issued_at: now,
121            operation_nature: "VENDA".to_string(),
122            items: Vec::new(),
123            recipient: None,
124            payments: Vec::new(),
125            change_amount: None,
126            payment_card_details: None,
127            contingency: None,
128            operation_type: None,
129            purpose_code: None,
130            intermediary_indicator: None,
131            emission_process: None,
132            consumer_type: None,
133            buyer_presence: None,
134            print_format: None,
135            references: None,
136            transport: None,
137            billing: None,
138            withdrawal: None,
139            delivery: None,
140            authorized_xml: None,
141            additional_info: None,
142            intermediary: None,
143            ret_trib: None,
144            tech_responsible: None,
145            purchase: None,
146            export: None,
147            result_xml: None,
148            result_access_key: None,
149            result_signed_xml: None,
150            _state: PhantomData,
151        }
152    }
153
154    // ── Chainable setters ────────────────────────────────────────────────
155
156    /// Set the invoice series (default: 1).
157    pub fn series(mut self, s: u32) -> Self {
158        self.series = s;
159        self
160    }
161
162    /// Set the invoice number (default: 1).
163    pub fn invoice_number(mut self, n: u32) -> Self {
164        self.invoice_number = n;
165        self
166    }
167
168    /// Set the emission type (default: [`EmissionType::Normal`]).
169    pub fn emission_type(mut self, et: EmissionType) -> Self {
170        self.emission_type = et;
171        self
172    }
173
174    /// Set the emission date/time (default: now in UTC-3).
175    pub fn issued_at(mut self, dt: DateTime<FixedOffset>) -> Self {
176        self.issued_at = dt;
177        self
178    }
179
180    /// Set the operation nature (default: `"VENDA"`).
181    pub fn operation_nature(mut self, n: impl Into<String>) -> Self {
182        self.operation_nature = n.into();
183        self
184    }
185
186    /// Add one item to the invoice.
187    pub fn add_item(mut self, item: InvoiceItemData) -> Self {
188        self.items.push(item);
189        self
190    }
191
192    /// Set all items at once (replaces any previously added items).
193    pub fn items(mut self, items: Vec<InvoiceItemData>) -> Self {
194        self.items = items;
195        self
196    }
197
198    /// Set the recipient (optional for NFC-e under R$200).
199    pub fn recipient(mut self, r: RecipientData) -> Self {
200        self.recipient = Some(r);
201        self
202    }
203
204    /// Set the payment list.
205    pub fn payments(mut self, p: Vec<PaymentData>) -> Self {
206        self.payments = p;
207        self
208    }
209
210    /// Set the change amount (vTroco).
211    pub fn change_amount(mut self, c: Cents) -> Self {
212        self.change_amount = Some(c);
213        self
214    }
215
216    /// Set card payment details.
217    pub fn payment_card_details(mut self, d: Vec<PaymentCardDetail>) -> Self {
218        self.payment_card_details = Some(d);
219        self
220    }
221
222    /// Set contingency data.
223    pub fn contingency(mut self, c: ContingencyData) -> Self {
224        self.contingency = Some(c);
225        self
226    }
227
228    /// Override the operation type (tpNF, default: 1).
229    pub fn operation_type(mut self, v: u8) -> Self {
230        self.operation_type = Some(v);
231        self
232    }
233
234    /// Override the invoice purpose code (finNFe, default: 1).
235    pub fn purpose_code(mut self, v: u8) -> Self {
236        self.purpose_code = Some(v);
237        self
238    }
239
240    /// Set the intermediary indicator (indIntermed).
241    pub fn intermediary_indicator(mut self, v: impl Into<String>) -> Self {
242        self.intermediary_indicator = Some(v.into());
243        self
244    }
245
246    /// Set the emission process (procEmi).
247    pub fn emission_process(mut self, v: impl Into<String>) -> Self {
248        self.emission_process = Some(v.into());
249        self
250    }
251
252    /// Set the consumer type (indFinal).
253    pub fn consumer_type(mut self, v: impl Into<String>) -> Self {
254        self.consumer_type = Some(v.into());
255        self
256    }
257
258    /// Set the buyer presence indicator (indPres).
259    pub fn buyer_presence(mut self, v: impl Into<String>) -> Self {
260        self.buyer_presence = Some(v.into());
261        self
262    }
263
264    /// Set the DANFE print format (tpImp).
265    pub fn print_format(mut self, v: impl Into<String>) -> Self {
266        self.print_format = Some(v.into());
267        self
268    }
269
270    /// Set referenced documents (NFref).
271    pub fn references(mut self, refs: Vec<ReferenceDoc>) -> Self {
272        self.references = Some(refs);
273        self
274    }
275
276    /// Set transport data.
277    pub fn transport(mut self, t: TransportData) -> Self {
278        self.transport = Some(t);
279        self
280    }
281
282    /// Set billing data (cobr).
283    pub fn billing(mut self, b: BillingData) -> Self {
284        self.billing = Some(b);
285        self
286    }
287
288    /// Set the withdrawal/pickup location (retirada).
289    pub fn withdrawal(mut self, w: LocationData) -> Self {
290        self.withdrawal = Some(w);
291        self
292    }
293
294    /// Set the delivery location (entrega).
295    pub fn delivery(mut self, d: LocationData) -> Self {
296        self.delivery = Some(d);
297        self
298    }
299
300    /// Set authorized XML downloaders (autXML).
301    pub fn authorized_xml(mut self, a: Vec<AuthorizedXml>) -> Self {
302        self.authorized_xml = Some(a);
303        self
304    }
305
306    /// Set additional info (infAdic).
307    pub fn additional_info(mut self, a: AdditionalInfo) -> Self {
308        self.additional_info = Some(a);
309        self
310    }
311
312    /// Set intermediary data (infIntermed).
313    pub fn intermediary(mut self, i: IntermediaryData) -> Self {
314        self.intermediary = Some(i);
315        self
316    }
317
318    /// Set retained taxes (retTrib).
319    pub fn ret_trib(mut self, r: RetTribData) -> Self {
320        self.ret_trib = Some(r);
321        self
322    }
323
324    /// Set tech responsible (infRespTec).
325    pub fn tech_responsible(mut self, t: TechResponsibleData) -> Self {
326        self.tech_responsible = Some(t);
327        self
328    }
329
330    /// Set purchase data (compra).
331    pub fn purchase(mut self, p: PurchaseData) -> Self {
332        self.purchase = Some(p);
333        self
334    }
335
336    /// Set export data (exporta).
337    pub fn export(mut self, e: ExportData) -> Self {
338        self.export = Some(e);
339        self
340    }
341
342    /// Validate and build the XML, transitioning to [`Built`].
343    ///
344    /// # Errors
345    ///
346    /// Returns [`FiscalError`] if:
347    /// - The issuer state code is unknown
348    /// - Tax data is invalid
349    pub fn build(self) -> Result<InvoiceBuilder<Built>, FiscalError> {
350        let data = InvoiceBuildData {
351            model: self.model,
352            series: self.series,
353            number: self.invoice_number,
354            emission_type: self.emission_type,
355            environment: self.environment,
356            issued_at: self.issued_at,
357            operation_nature: self.operation_nature,
358            issuer: self.issuer,
359            recipient: self.recipient,
360            items: self.items,
361            payments: self.payments,
362            change_amount: self.change_amount,
363            payment_card_details: self.payment_card_details,
364            contingency: self.contingency,
365            operation_type: self.operation_type,
366            purpose_code: self.purpose_code,
367            intermediary_indicator: self.intermediary_indicator,
368            emission_process: self.emission_process,
369            consumer_type: self.consumer_type,
370            buyer_presence: self.buyer_presence,
371            print_format: self.print_format,
372            references: self.references,
373            transport: self.transport,
374            billing: self.billing,
375            withdrawal: self.withdrawal,
376            delivery: self.delivery,
377            authorized_xml: self.authorized_xml,
378            additional_info: self.additional_info,
379            intermediary: self.intermediary,
380            ret_trib: self.ret_trib,
381            tech_responsible: self.tech_responsible,
382            purchase: self.purchase,
383            export: self.export,
384        };
385
386        let result = super::generate_xml(&data)?;
387
388        Ok(InvoiceBuilder {
389            issuer: data.issuer,
390            environment: data.environment,
391            model: data.model,
392            series: data.series,
393            invoice_number: data.number,
394            emission_type: data.emission_type,
395            issued_at: data.issued_at,
396            operation_nature: data.operation_nature,
397            items: data.items,
398            recipient: data.recipient,
399            payments: data.payments,
400            change_amount: data.change_amount,
401            payment_card_details: data.payment_card_details,
402            contingency: data.contingency,
403            operation_type: data.operation_type,
404            purpose_code: data.purpose_code,
405            intermediary_indicator: data.intermediary_indicator,
406            emission_process: data.emission_process,
407            consumer_type: data.consumer_type,
408            buyer_presence: data.buyer_presence,
409            print_format: data.print_format,
410            references: data.references,
411            transport: data.transport,
412            billing: data.billing,
413            withdrawal: data.withdrawal,
414            delivery: data.delivery,
415            authorized_xml: data.authorized_xml,
416            additional_info: data.additional_info,
417            intermediary: data.intermediary,
418            ret_trib: data.ret_trib,
419            tech_responsible: data.tech_responsible,
420            purchase: data.purchase,
421            export: data.export,
422            result_xml: Some(result.xml),
423            result_access_key: Some(result.access_key),
424            result_signed_xml: None,
425            _state: PhantomData,
426        })
427    }
428}
429
430// ── Built methods (accessors) ────────────────────────────────────────────────
431
432impl InvoiceBuilder<Built> {
433    /// The unsigned XML string.
434    pub fn xml(&self) -> &str {
435        self.result_xml
436            .as_deref()
437            .expect("Built state always has XML")
438    }
439
440    /// The 44-digit access key.
441    pub fn access_key(&self) -> &str {
442        self.result_access_key
443            .as_deref()
444            .expect("Built state always has access key")
445    }
446
447    /// Sign the XML using the provided signing function.
448    ///
449    /// The signing function receives the unsigned XML and must return
450    /// the signed XML or an error. This keeps `fiscal-core` independent
451    /// of the crypto implementation.
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// # use fiscal_core::xml_builder::{InvoiceBuilder, Draft, Built, Signed};
457    /// # use fiscal_core::FiscalError;
458    /// // Assuming `builder` is an InvoiceBuilder<Built>:
459    /// # fn example(builder: InvoiceBuilder<Built>) -> Result<(), FiscalError> {
460    /// let signed = builder.sign_with(|xml| {
461    ///     // In real code, call fiscal_crypto::certificate::sign_xml() here.
462    ///     Ok(format!("{xml}<Signature/>"))
463    /// })?;
464    /// assert!(signed.signed_xml().contains("<Signature/>"));
465    /// # Ok(())
466    /// # }
467    /// ```
468    ///
469    /// # Errors
470    ///
471    /// Returns [`FiscalError`] if the signing function returns an error.
472    pub fn sign_with<F>(self, signer: F) -> Result<InvoiceBuilder<Signed>, FiscalError>
473    where
474        F: FnOnce(&str) -> Result<String, FiscalError>,
475    {
476        let unsigned_xml = self
477            .result_xml
478            .as_deref()
479            .expect("Built state always has XML");
480
481        let signed_xml = signer(unsigned_xml)?;
482
483        Ok(InvoiceBuilder {
484            issuer: self.issuer,
485            environment: self.environment,
486            model: self.model,
487            series: self.series,
488            invoice_number: self.invoice_number,
489            emission_type: self.emission_type,
490            issued_at: self.issued_at,
491            operation_nature: self.operation_nature,
492            items: self.items,
493            recipient: self.recipient,
494            payments: self.payments,
495            change_amount: self.change_amount,
496            payment_card_details: self.payment_card_details,
497            contingency: self.contingency,
498            operation_type: self.operation_type,
499            purpose_code: self.purpose_code,
500            intermediary_indicator: self.intermediary_indicator,
501            emission_process: self.emission_process,
502            consumer_type: self.consumer_type,
503            buyer_presence: self.buyer_presence,
504            print_format: self.print_format,
505            references: self.references,
506            transport: self.transport,
507            billing: self.billing,
508            withdrawal: self.withdrawal,
509            delivery: self.delivery,
510            authorized_xml: self.authorized_xml,
511            additional_info: self.additional_info,
512            intermediary: self.intermediary,
513            ret_trib: self.ret_trib,
514            tech_responsible: self.tech_responsible,
515            purchase: self.purchase,
516            export: self.export,
517            result_xml: self.result_xml,
518            result_access_key: self.result_access_key,
519            result_signed_xml: Some(signed_xml),
520            _state: PhantomData,
521        })
522    }
523}
524
525// ── Signed methods (accessors) ──────────────────────────────────────────────
526
527impl InvoiceBuilder<Signed> {
528    /// The signed XML string (includes `<Signature>` element).
529    pub fn signed_xml(&self) -> &str {
530        self.result_signed_xml
531            .as_deref()
532            .expect("Signed state always has signed XML")
533    }
534
535    /// The 44-digit access key.
536    pub fn access_key(&self) -> &str {
537        self.result_access_key
538            .as_deref()
539            .expect("Signed state always has access key")
540    }
541
542    /// The unsigned XML (before signing).
543    pub fn unsigned_xml(&self) -> &str {
544        self.result_xml
545            .as_deref()
546            .expect("Signed state always has unsigned XML")
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::newtypes::{Cents, IbgeCode, Rate};
554    use crate::types::{
555        InvoiceItemData, InvoiceModel, IssuerData, PaymentData, SefazEnvironment, TaxRegime,
556    };
557
558    /// Standard Brazilian timezone offset (UTC-3).
559    fn br_offset() -> chrono::FixedOffset {
560        chrono::FixedOffset::west_opt(3 * 3600).unwrap()
561    }
562
563    /// Build a minimal InvoiceBuilder in Draft state.
564    fn sample_builder() -> InvoiceBuilder<Draft> {
565        let issuer = IssuerData::new(
566            "12345678000199",
567            "123456789",
568            "Test Company",
569            TaxRegime::SimplesNacional,
570            "SP",
571            IbgeCode("3550308".to_string()),
572            "Sao Paulo",
573            "Av Paulista",
574            "1000",
575            "Bela Vista",
576            "01310100",
577        )
578        .trade_name("Test");
579
580        let item = InvoiceItemData::new(
581            1,
582            "1",
583            "Product A",
584            "84715010",
585            "5102",
586            "UN",
587            2.0,
588            Cents(1000),
589            Cents(2000),
590            "102",
591            Rate(0),
592            Cents(0),
593            "99",
594            "99",
595        );
596
597        let payment = PaymentData::new("01", Cents(2000));
598
599        let offset = br_offset();
600        let issued_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
601            .unwrap()
602            .and_hms_opt(10, 30, 0)
603            .unwrap()
604            .and_local_timezone(offset)
605            .unwrap();
606
607        InvoiceBuilder::new(issuer, SefazEnvironment::Homologation, InvoiceModel::Nfce)
608            .series(1)
609            .invoice_number(1)
610            .issued_at(issued_at)
611            .add_item(item)
612            .payments(vec![payment])
613    }
614
615    /// Build a minimal InvoiceBuilder<Built>.
616    fn built_builder() -> InvoiceBuilder<Built> {
617        sample_builder().build().expect("build should succeed")
618    }
619
620    #[test]
621    fn sign_with_identity_fn() {
622        let built = built_builder();
623        let original_xml = built.xml().to_string();
624
625        let signed = built
626            .sign_with(|xml| Ok(xml.to_string()))
627            .expect("identity signer should not fail");
628
629        assert_eq!(signed.signed_xml(), original_xml);
630    }
631
632    #[test]
633    fn sign_with_failing_fn() {
634        let built = built_builder();
635
636        let result =
637            built.sign_with(|_xml| Err(FiscalError::Certificate("test signing failure".into())));
638
639        let err = match result {
640            Err(e) => e,
641            Ok(_) => panic!("expected sign_with to return Err"),
642        };
643        assert_eq!(err, FiscalError::Certificate("test signing failure".into()),);
644    }
645
646    #[test]
647    fn signed_accessors() {
648        let built = built_builder();
649        let original_xml = built.xml().to_string();
650        let original_key = built.access_key().to_string();
651
652        let signed = built
653            .sign_with(|xml| Ok(format!("{xml}<Signature/>")))
654            .expect("signer should succeed");
655
656        assert_eq!(signed.signed_xml(), format!("{original_xml}<Signature/>"),);
657        assert_eq!(signed.access_key(), original_key);
658        assert_eq!(signed.unsigned_xml(), original_xml);
659    }
660
661    #[test]
662    fn built_still_works() {
663        let built = built_builder();
664
665        // Verify Built accessors are available and correct.
666        let xml = built.xml();
667        assert!(xml.contains("<NFe"));
668        assert!(xml.contains("</NFe>"));
669        assert!(xml.contains("<infNFe"));
670
671        let key = built.access_key();
672        assert_eq!(key.len(), 44);
673        assert!(key.chars().all(|c| c.is_ascii_digit()));
674    }
675}