use std::marker::PhantomData;
use chrono::{DateTime, FixedOffset};
use crate::FiscalError;
use crate::newtypes::Cents;
use crate::types::*;
pub struct Draft;
pub struct Built;
pub struct Signed;
pub struct InvoiceBuilder<State = Draft> {
issuer: IssuerData,
environment: SefazEnvironment,
model: InvoiceModel,
schema_version: SchemaVersion,
series: u32,
invoice_number: u32,
emission_type: EmissionType,
issued_at: DateTime<FixedOffset>,
operation_nature: String,
items: Vec<InvoiceItemData>,
recipient: Option<RecipientData>,
payments: Vec<PaymentData>,
change_amount: Option<Cents>,
payment_card_details: Option<Vec<PaymentCardDetail>>,
contingency: Option<ContingencyData>,
exit_at: Option<DateTime<FixedOffset>>,
operation_type: Option<u8>,
purpose_code: Option<u8>,
destination_indicator: Option<String>,
intermediary_indicator: Option<String>,
emission_process: Option<String>,
consumer_type: Option<String>,
buyer_presence: Option<String>,
print_format: Option<String>,
ver_proc: Option<String>,
references: Option<Vec<ReferenceDoc>>,
transport: Option<TransportData>,
billing: Option<BillingData>,
withdrawal: Option<LocationData>,
delivery: Option<LocationData>,
authorized_xml: Option<Vec<AuthorizedXml>>,
additional_info: Option<AdditionalInfo>,
intermediary: Option<IntermediaryData>,
ret_trib: Option<RetTribData>,
tech_responsible: Option<TechResponsibleData>,
purchase: Option<PurchaseData>,
export: Option<ExportData>,
issqn_tot: Option<IssqnTotData>,
cana: Option<CanaData>,
agropecuario: Option<AgropecuarioData>,
compra_gov: Option<CompraGovData>,
pag_antecipado: Option<PagAntecipadoData>,
is_tot: Option<crate::tax_ibs_cbs::IsTotData>,
ibs_cbs_tot: Option<crate::tax_ibs_cbs::IbsCbsTotData>,
v_nf_tot_override: Option<Cents>,
only_ascii: bool,
calculation_method: crate::types::CalculationMethod,
result_xml: Option<String>,
result_access_key: Option<String>,
result_signed_xml: Option<String>,
_state: PhantomData<State>,
}
impl InvoiceBuilder<Draft> {
pub fn new(issuer: IssuerData, environment: SefazEnvironment, model: InvoiceModel) -> Self {
let now = chrono::Utc::now()
.with_timezone(&FixedOffset::west_opt(3 * 3600).expect("valid offset"));
Self {
issuer,
environment,
model,
schema_version: SchemaVersion::default(),
series: 1,
invoice_number: 1,
emission_type: EmissionType::Normal,
issued_at: now,
operation_nature: "VENDA".to_string(),
items: Vec::new(),
recipient: None,
payments: Vec::new(),
change_amount: None,
payment_card_details: None,
contingency: None,
exit_at: None,
operation_type: None,
purpose_code: None,
destination_indicator: None,
intermediary_indicator: None,
emission_process: None,
consumer_type: None,
buyer_presence: None,
print_format: None,
ver_proc: None,
references: None,
transport: None,
billing: None,
withdrawal: None,
delivery: None,
authorized_xml: None,
additional_info: None,
intermediary: None,
ret_trib: None,
tech_responsible: None,
purchase: None,
export: None,
issqn_tot: None,
cana: None,
agropecuario: None,
compra_gov: None,
pag_antecipado: None,
is_tot: None,
ibs_cbs_tot: None,
v_nf_tot_override: None,
only_ascii: false,
calculation_method: crate::types::CalculationMethod::V2,
result_xml: None,
result_access_key: None,
result_signed_xml: None,
_state: PhantomData,
}
}
pub fn series(mut self, s: u32) -> Self {
self.series = s;
self
}
pub fn invoice_number(mut self, n: u32) -> Self {
self.invoice_number = n;
self
}
pub fn emission_type(mut self, et: EmissionType) -> Self {
self.emission_type = et;
self
}
pub fn schema_version(mut self, sv: SchemaVersion) -> Self {
self.schema_version = sv;
self
}
pub fn issued_at(mut self, dt: DateTime<FixedOffset>) -> Self {
self.issued_at = dt;
self
}
pub fn operation_nature(mut self, n: impl Into<String>) -> Self {
self.operation_nature = n.into();
self
}
pub fn add_item(mut self, item: InvoiceItemData) -> Self {
self.items.push(item);
self
}
pub fn items(mut self, items: Vec<InvoiceItemData>) -> Self {
self.items = items;
self
}
pub fn recipient(mut self, r: RecipientData) -> Self {
self.recipient = Some(r);
self
}
pub fn payments(mut self, p: Vec<PaymentData>) -> Self {
self.payments = p;
self
}
pub fn change_amount(mut self, c: Cents) -> Self {
self.change_amount = Some(c);
self
}
pub fn payment_card_details(mut self, d: Vec<PaymentCardDetail>) -> Self {
self.payment_card_details = Some(d);
self
}
pub fn contingency(mut self, c: ContingencyData) -> Self {
self.contingency = Some(c);
self
}
pub fn exit_at(mut self, dt: DateTime<FixedOffset>) -> Self {
self.exit_at = Some(dt);
self
}
pub fn operation_type(mut self, v: u8) -> Self {
self.operation_type = Some(v);
self
}
pub fn purpose_code(mut self, v: u8) -> Self {
self.purpose_code = Some(v);
self
}
pub fn intermediary_indicator(mut self, v: impl Into<String>) -> Self {
self.intermediary_indicator = Some(v.into());
self
}
pub fn emission_process(mut self, v: impl Into<String>) -> Self {
self.emission_process = Some(v.into());
self
}
pub fn consumer_type(mut self, v: impl Into<String>) -> Self {
self.consumer_type = Some(v.into());
self
}
pub fn buyer_presence(mut self, v: impl Into<String>) -> Self {
self.buyer_presence = Some(v.into());
self
}
pub fn print_format(mut self, v: impl Into<String>) -> Self {
self.print_format = Some(v.into());
self
}
pub fn destination_indicator(mut self, v: impl Into<String>) -> Self {
self.destination_indicator = Some(v.into());
self
}
pub fn ver_proc(mut self, v: impl Into<String>) -> Self {
self.ver_proc = Some(v.into());
self
}
pub fn references(mut self, refs: Vec<ReferenceDoc>) -> Self {
self.references = Some(refs);
self
}
pub fn transport(mut self, t: TransportData) -> Self {
self.transport = Some(t);
self
}
pub fn billing(mut self, b: BillingData) -> Self {
self.billing = Some(b);
self
}
pub fn withdrawal(mut self, w: LocationData) -> Self {
self.withdrawal = Some(w);
self
}
pub fn delivery(mut self, d: LocationData) -> Self {
self.delivery = Some(d);
self
}
pub fn authorized_xml(mut self, a: Vec<AuthorizedXml>) -> Self {
self.authorized_xml = Some(a);
self
}
pub fn additional_info(mut self, a: AdditionalInfo) -> Self {
self.additional_info = Some(a);
self
}
pub fn intermediary(mut self, i: IntermediaryData) -> Self {
self.intermediary = Some(i);
self
}
pub fn ret_trib(mut self, r: RetTribData) -> Self {
self.ret_trib = Some(r);
self
}
pub fn tech_responsible(mut self, t: TechResponsibleData) -> Self {
self.tech_responsible = Some(t);
self
}
pub fn purchase(mut self, p: PurchaseData) -> Self {
self.purchase = Some(p);
self
}
pub fn export(mut self, e: ExportData) -> Self {
self.export = Some(e);
self
}
pub fn issqn_tot(mut self, t: IssqnTotData) -> Self {
self.issqn_tot = Some(t);
self
}
pub fn cana(mut self, c: CanaData) -> Self {
self.cana = Some(c);
self
}
pub fn agropecuario(mut self, a: AgropecuarioData) -> Self {
self.agropecuario = Some(a);
self
}
pub fn compra_gov(mut self, c: CompraGovData) -> Self {
self.compra_gov = Some(c);
self
}
pub fn pag_antecipado(mut self, p: PagAntecipadoData) -> Self {
self.pag_antecipado = Some(p);
self
}
pub fn is_tot(mut self, t: crate::tax_ibs_cbs::IsTotData) -> Self {
self.is_tot = Some(t);
self
}
pub fn ibs_cbs_tot(mut self, t: crate::tax_ibs_cbs::IbsCbsTotData) -> Self {
self.ibs_cbs_tot = Some(t);
self
}
pub fn only_ascii(mut self, enabled: bool) -> Self {
self.only_ascii = enabled;
self
}
pub fn calculation_method(mut self, m: crate::types::CalculationMethod) -> Self {
self.calculation_method = m;
self
}
pub fn v_nf_tot_override(mut self, v: Cents) -> Self {
self.v_nf_tot_override = Some(v);
self
}
pub fn build(self) -> Result<InvoiceBuilder<Built>, FiscalError> {
let data = InvoiceBuildData {
schema_version: self.schema_version,
model: self.model,
series: self.series,
number: self.invoice_number,
emission_type: self.emission_type,
environment: self.environment,
issued_at: self.issued_at,
operation_nature: self.operation_nature,
issuer: self.issuer,
recipient: self.recipient,
items: self.items,
payments: self.payments,
change_amount: self.change_amount,
payment_card_details: self.payment_card_details,
contingency: self.contingency,
exit_at: self.exit_at,
operation_type: self.operation_type,
purpose_code: self.purpose_code,
destination_indicator: self.destination_indicator,
intermediary_indicator: self.intermediary_indicator,
emission_process: self.emission_process,
consumer_type: self.consumer_type,
buyer_presence: self.buyer_presence,
print_format: self.print_format,
ver_proc: self.ver_proc,
references: self.references,
transport: self.transport,
billing: self.billing,
withdrawal: self.withdrawal,
delivery: self.delivery,
authorized_xml: self.authorized_xml,
additional_info: self.additional_info,
intermediary: self.intermediary,
ret_trib: self.ret_trib,
tech_responsible: self.tech_responsible,
purchase: self.purchase,
export: self.export,
issqn_tot: self.issqn_tot,
cana: self.cana,
agropecuario: self.agropecuario,
compra_gov: self.compra_gov,
pag_antecipado: self.pag_antecipado,
is_tot: self.is_tot,
ibs_cbs_tot: self.ibs_cbs_tot,
v_nf_tot_override: self.v_nf_tot_override,
only_ascii: self.only_ascii,
calculation_method: self.calculation_method,
};
let result = super::generate_xml(&data)?;
Ok(InvoiceBuilder {
issuer: data.issuer,
environment: data.environment,
model: data.model,
schema_version: data.schema_version,
series: data.series,
invoice_number: data.number,
emission_type: data.emission_type,
issued_at: data.issued_at,
operation_nature: data.operation_nature,
items: data.items,
recipient: data.recipient,
payments: data.payments,
change_amount: data.change_amount,
payment_card_details: data.payment_card_details,
contingency: data.contingency,
exit_at: data.exit_at,
operation_type: data.operation_type,
purpose_code: data.purpose_code,
destination_indicator: data.destination_indicator,
intermediary_indicator: data.intermediary_indicator,
emission_process: data.emission_process,
consumer_type: data.consumer_type,
buyer_presence: data.buyer_presence,
print_format: data.print_format,
ver_proc: data.ver_proc,
references: data.references,
transport: data.transport,
billing: data.billing,
withdrawal: data.withdrawal,
delivery: data.delivery,
authorized_xml: data.authorized_xml,
additional_info: data.additional_info,
intermediary: data.intermediary,
ret_trib: data.ret_trib,
tech_responsible: data.tech_responsible,
purchase: data.purchase,
export: data.export,
issqn_tot: data.issqn_tot,
cana: data.cana,
agropecuario: data.agropecuario,
compra_gov: data.compra_gov,
pag_antecipado: data.pag_antecipado,
is_tot: data.is_tot,
ibs_cbs_tot: data.ibs_cbs_tot,
v_nf_tot_override: data.v_nf_tot_override,
only_ascii: data.only_ascii,
calculation_method: data.calculation_method,
result_xml: Some(result.xml),
result_access_key: Some(result.access_key),
result_signed_xml: None,
_state: PhantomData,
})
}
}
impl InvoiceBuilder<Built> {
pub fn xml(&self) -> &str {
self.result_xml
.as_deref()
.expect("Built state always has XML")
}
pub fn access_key(&self) -> &str {
self.result_access_key
.as_deref()
.expect("Built state always has access key")
}
pub fn sign_with<F>(self, signer: F) -> Result<InvoiceBuilder<Signed>, FiscalError>
where
F: FnOnce(&str) -> Result<String, FiscalError>,
{
let unsigned_xml = self
.result_xml
.as_deref()
.expect("Built state always has XML");
let signed_xml = signer(unsigned_xml)?;
Ok(InvoiceBuilder {
issuer: self.issuer,
environment: self.environment,
model: self.model,
schema_version: self.schema_version,
series: self.series,
invoice_number: self.invoice_number,
emission_type: self.emission_type,
issued_at: self.issued_at,
operation_nature: self.operation_nature,
items: self.items,
recipient: self.recipient,
payments: self.payments,
change_amount: self.change_amount,
payment_card_details: self.payment_card_details,
contingency: self.contingency,
exit_at: self.exit_at,
operation_type: self.operation_type,
purpose_code: self.purpose_code,
destination_indicator: self.destination_indicator,
intermediary_indicator: self.intermediary_indicator,
emission_process: self.emission_process,
consumer_type: self.consumer_type,
buyer_presence: self.buyer_presence,
print_format: self.print_format,
ver_proc: self.ver_proc,
references: self.references,
transport: self.transport,
billing: self.billing,
withdrawal: self.withdrawal,
delivery: self.delivery,
authorized_xml: self.authorized_xml,
additional_info: self.additional_info,
intermediary: self.intermediary,
ret_trib: self.ret_trib,
tech_responsible: self.tech_responsible,
purchase: self.purchase,
export: self.export,
issqn_tot: self.issqn_tot,
cana: self.cana,
agropecuario: self.agropecuario,
compra_gov: self.compra_gov,
pag_antecipado: self.pag_antecipado,
is_tot: self.is_tot,
ibs_cbs_tot: self.ibs_cbs_tot,
v_nf_tot_override: self.v_nf_tot_override,
only_ascii: self.only_ascii,
calculation_method: self.calculation_method,
result_xml: self.result_xml,
result_access_key: self.result_access_key,
result_signed_xml: Some(signed_xml),
_state: PhantomData,
})
}
}
impl InvoiceBuilder<Signed> {
pub fn signed_xml(&self) -> &str {
self.result_signed_xml
.as_deref()
.expect("Signed state always has signed XML")
}
pub fn access_key(&self) -> &str {
self.result_access_key
.as_deref()
.expect("Signed state always has access key")
}
pub fn unsigned_xml(&self) -> &str {
self.result_xml
.as_deref()
.expect("Signed state always has unsigned XML")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::newtypes::{Cents, IbgeCode, Rate};
use crate::types::{
InvoiceItemData, InvoiceModel, IssuerData, PaymentData, SefazEnvironment, TaxRegime,
};
fn br_offset() -> chrono::FixedOffset {
chrono::FixedOffset::west_opt(3 * 3600).unwrap()
}
fn sample_builder() -> InvoiceBuilder<Draft> {
let issuer = IssuerData::new(
"12345678000199",
"123456789",
"Test Company",
TaxRegime::SimplesNacional,
"SP",
IbgeCode("3550308".to_string()),
"Sao Paulo",
"Av Paulista",
"1000",
"Bela Vista",
"01310100",
)
.trade_name("Test");
let item = InvoiceItemData::new(
1,
"1",
"Product A",
"84715010",
"5102",
"UN",
2.0,
Cents(1000),
Cents(2000),
"102",
Rate(0),
Cents(0),
"99",
"99",
);
let payment = PaymentData::new("01", Cents(2000));
let offset = br_offset();
let issued_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
.and_local_timezone(offset)
.unwrap();
InvoiceBuilder::new(issuer, SefazEnvironment::Homologation, InvoiceModel::Nfce)
.series(1)
.invoice_number(1)
.issued_at(issued_at)
.add_item(item)
.payments(vec![payment])
}
fn built_builder() -> InvoiceBuilder<Built> {
sample_builder().build().expect("build should succeed")
}
#[test]
fn sign_with_identity_fn() {
let built = built_builder();
let original_xml = built.xml().to_string();
let signed = built
.sign_with(|xml| Ok(xml.to_string()))
.expect("identity signer should not fail");
assert_eq!(signed.signed_xml(), original_xml);
}
#[test]
fn sign_with_failing_fn() {
let built = built_builder();
let result =
built.sign_with(|_xml| Err(FiscalError::Certificate("test signing failure".into())));
let err = match result {
Err(e) => e,
Ok(_) => panic!("expected sign_with to return Err"),
};
assert_eq!(err, FiscalError::Certificate("test signing failure".into()),);
}
#[test]
fn signed_accessors() {
let built = built_builder();
let original_xml = built.xml().to_string();
let original_key = built.access_key().to_string();
let signed = built
.sign_with(|xml| Ok(format!("{xml}<Signature/>")))
.expect("signer should succeed");
assert_eq!(signed.signed_xml(), format!("{original_xml}<Signature/>"),);
assert_eq!(signed.access_key(), original_key);
assert_eq!(signed.unsigned_xml(), original_xml);
}
#[test]
fn built_still_works() {
let built = built_builder();
let xml = built.xml();
assert!(xml.contains("<NFe"));
assert!(xml.contains("</NFe>"));
assert!(xml.contains("<infNFe"));
let key = built.access_key();
assert_eq!(key.len(), 44);
assert!(key.chars().all(|c| c.is_ascii_digit()));
}
fn nfe_builder() -> InvoiceBuilder<Draft> {
let issuer = IssuerData::new(
"12345678000199",
"123456789",
"Test Company",
TaxRegime::SimplesNacional,
"SP",
IbgeCode("3550308".to_string()),
"Sao Paulo",
"Av Paulista",
"1000",
"Bela Vista",
"01310100",
)
.trade_name("Test");
let item = InvoiceItemData::new(
1,
"1",
"Product A",
"84715010",
"5102",
"UN",
2.0,
Cents(1000),
Cents(2000),
"102",
Rate(0),
Cents(0),
"99",
"99",
);
let payment = PaymentData::new("01", Cents(2000));
let offset = br_offset();
let issued_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
.and_local_timezone(offset)
.unwrap();
InvoiceBuilder::new(issuer, SefazEnvironment::Homologation, InvoiceModel::Nfe)
.series(1)
.invoice_number(1)
.issued_at(issued_at)
.add_item(item)
.payments(vec![payment])
}
#[test]
fn dh_sai_ent_emitted_for_model_55() {
let offset = br_offset();
let exit = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(14, 0, 0)
.unwrap()
.and_local_timezone(offset)
.unwrap();
let built = nfe_builder()
.exit_at(exit)
.build()
.expect("build should succeed");
let xml = built.xml();
assert!(
xml.contains("<dhSaiEnt>2026-01-15T14:00:00-03:00</dhSaiEnt>"),
"NF-e (model 55) with exit_at must emit <dhSaiEnt>, got:\n{xml}"
);
let emi_pos = xml.find("<dhEmi>").expect("dhEmi must be present");
let sai_pos = xml.find("<dhSaiEnt>").expect("dhSaiEnt must be present");
let tp_nf_pos = xml.find("<tpNF>").expect("tpNF must be present");
assert!(
emi_pos < sai_pos && sai_pos < tp_nf_pos,
"dhSaiEnt must come after dhEmi and before tpNF"
);
}
#[test]
fn dh_sai_ent_omitted_for_model_65() {
let offset = br_offset();
let exit = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(14, 0, 0)
.unwrap()
.and_local_timezone(offset)
.unwrap();
let built = sample_builder()
.exit_at(exit)
.build()
.expect("build should succeed");
let xml = built.xml();
assert!(
!xml.contains("<dhSaiEnt>"),
"NFC-e (model 65) must NOT emit <dhSaiEnt>, got:\n{xml}"
);
}
#[test]
fn dh_sai_ent_omitted_when_not_set() {
let built = nfe_builder().build().expect("build should succeed");
let xml = built.xml();
assert!(
!xml.contains("<dhSaiEnt>"),
"NF-e without exit_at must NOT emit <dhSaiEnt>"
);
}
#[test]
fn dh_cont_and_x_just_emitted_in_contingency() {
use crate::types::{ContingencyData, ContingencyType};
let offset = br_offset();
let cont_at = chrono::NaiveDate::from_ymd_opt(2026, 1, 15)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap()
.and_local_timezone(offset)
.unwrap();
let contingency = ContingencyData::new(
ContingencyType::SvcAn,
"SEFAZ fora do ar para manutencao programada",
cont_at,
);
let built = nfe_builder()
.contingency(contingency)
.build()
.expect("build should succeed");
let xml = built.xml();
assert!(
xml.contains("<dhCont>2026-01-15T09:00:00-03:00</dhCont>"),
"Contingency must emit <dhCont>, got:\n{xml}"
);
assert!(
xml.contains("<xJust>SEFAZ fora do ar para manutencao programada</xJust>"),
"Contingency must emit <xJust>, got:\n{xml}"
);
let ver_proc_pos = xml.find("<verProc>").expect("verProc must be present");
let dh_cont_pos = xml.find("<dhCont>").expect("dhCont must be present");
let x_just_pos = xml.find("<xJust>").expect("xJust must be present");
assert!(
ver_proc_pos < dh_cont_pos && dh_cont_pos < x_just_pos,
"dhCont must come after verProc, xJust must come after dhCont"
);
}
#[test]
fn dh_cont_omitted_without_contingency() {
let built = nfe_builder().build().expect("build should succeed");
let xml = built.xml();
assert!(
!xml.contains("<dhCont>"),
"Without contingency, <dhCont> must NOT be present"
);
assert!(
!xml.contains("<xJust>"),
"Without contingency, <xJust> must NOT be present"
);
}
}