Skip to main content

fiscal_core/convert/
mod.rs

1//! TXT-to-XML converter for NF-e/NFC-e.
2//!
3//! Converts pipe-delimited TXT files (official SEFAZ layout) into NF-e XML.
4
5mod build_det;
6mod build_header;
7mod build_optional;
8mod build_parties;
9mod build_tax;
10mod build_totals;
11mod build_xml;
12mod helpers;
13mod parser;
14mod structures;
15mod types;
16
17use std::collections::HashMap;
18
19use crate::FiscalError;
20
21use helpers::validate_txt_lines;
22use parser::NFeParser;
23use structures::*;
24
25// ── TXT structures ──────────────────────────────────────────────────────────
26
27/// Return the TXT field structure map for a given version string and layout.
28///
29/// # Errors
30///
31/// Returns [`FiscalError::InvalidTxt`] if the version/layout combination
32/// is not supported.
33pub(super) fn get_structure(
34    version: &str,
35    layout: &str,
36) -> Result<HashMap<&'static str, &'static str>, FiscalError> {
37    let ver: u32 = version.replace('.', "").parse().unwrap_or(0);
38    let lay = layout.to_uppercase();
39
40    if ver == 310 {
41        return Ok(structure_310());
42    }
43    if ver == 400 {
44        if lay == "SEBRAE" {
45            return Ok(structure_400_sebrae());
46        }
47        if lay == "LOCAL_V12" {
48            return Ok(structure_400_v12());
49        }
50        if lay == "LOCAL_V13" {
51            return Ok(structure_400_v13());
52        }
53        return Ok(structure_400());
54    }
55
56    Err(FiscalError::InvalidTxt(format!(
57        "Structure definition for TXT layout version {version} ({layout}) was not found."
58    )))
59}
60
61// ── Public API ──────────────────────────────────────────────────────────────
62
63/// Convert SPED TXT format to NF-e XML (first invoice only).
64///
65/// Convenience wrapper around [`txt_to_xml_all`] that returns only the first
66/// invoice XML. Use this when you know the TXT contains a single NF-e.
67///
68/// # Errors
69///
70/// Returns [`FiscalError::InvalidTxt`] if the TXT is empty, not a valid
71/// NOTAFISCAL document, has structural errors, or if the access key is
72/// malformed. Returns [`FiscalError::WrongDocument`] if the document header
73/// is missing.
74pub fn txt_to_xml(txt: &str, layout: &str) -> Result<String, FiscalError> {
75    let mut xmls = txt_to_xml_all(txt, layout)?;
76    // txt_to_xml_all guarantees at least one element on success.
77    Ok(xmls.swap_remove(0))
78}
79
80/// Convert SPED TXT format to NF-e XML for **all** invoices in the file.
81///
82/// Parses the pipe-delimited TXT representation of one or more NF-e invoices
83/// and produces a `Vec<String>` containing the XML for each invoice, in the
84/// same order they appear in the TXT. Supports layouts:
85/// `"local"`, `"local_v12"`, `"local_v13"`, `"sebrae"`.
86///
87/// This mirrors the PHP `Convert::parse()` / `toXml()` behaviour which
88/// returns an array of XML strings — one per nota fiscal.
89///
90/// # Errors
91///
92/// Returns [`FiscalError::InvalidTxt`] if the TXT is empty, not a valid
93/// NOTAFISCAL document, has structural errors, or if the access key is
94/// malformed. Returns [`FiscalError::WrongDocument`] if the document header
95/// is missing or the declared invoice count does not match.
96pub fn txt_to_xml_all(txt: &str, layout: &str) -> Result<Vec<String>, FiscalError> {
97    let txt = txt.trim();
98    if txt.is_empty() {
99        return Err(FiscalError::WrongDocument("Empty document".into()));
100    }
101
102    let lines: Vec<&str> = txt.lines().collect();
103    let first_fields: Vec<&str> = lines[0].split('|').collect();
104    if first_fields[0] != "NOTAFISCAL" {
105        return Err(FiscalError::WrongDocument(
106            "Wrong document: not a valid NFe TXT".into(),
107        ));
108    }
109
110    let declared_count: usize = first_fields
111        .get(1)
112        .and_then(|s| s.parse().ok())
113        .unwrap_or(0);
114    let rest: Vec<&str> = lines[1..].to_vec();
115
116    // Slice invoices
117    let invoices = slice_invoices(&rest, declared_count);
118    if invoices.len() != declared_count {
119        return Err(FiscalError::WrongDocument(format!(
120            "Number of NFe declared ({declared_count}) does not match found ({})",
121            invoices.len()
122        )));
123    }
124
125    let norm_layout = normalize_layout(layout);
126    let mut xmls = Vec::with_capacity(declared_count);
127
128    for invoice in &invoices {
129        let version = extract_layout_version(invoice)?;
130
131        // Validate
132        let errors = validate_txt_lines(invoice, &norm_layout);
133        if !errors.is_empty() {
134            return Err(FiscalError::InvalidTxt(errors.join("\n")));
135        }
136
137        // Parse
138        let structure = get_structure(&version, &norm_layout)?;
139        let mut parser = NFeParser::new(&version, &norm_layout, &structure);
140        parser.parse(invoice);
141
142        // Validate access key
143        if !parser.inf_nfe_id.is_empty() {
144            let key = parser
145                .inf_nfe_id
146                .strip_prefix("NFe")
147                .unwrap_or(&parser.inf_nfe_id);
148            if !key.is_empty() && key.len() != 44 {
149                return Err(FiscalError::InvalidTxt(format!(
150                    "A chave informada est\u{e1} incorreta [{}]",
151                    parser.inf_nfe_id
152                )));
153            }
154        }
155
156        xmls.push(parser.build_xml());
157    }
158
159    Ok(xmls)
160}
161
162/// Validate TXT format structure without converting to XML.
163///
164/// Returns `Ok(true)` if the TXT passes structural validation, or
165/// `Ok(false)` / `Err` if validation errors are found.
166///
167/// # Errors
168///
169/// Returns [`FiscalError::WrongDocument`] if the document header is missing
170/// or empty.
171pub fn validate_txt(txt: &str, layout: &str) -> Result<bool, FiscalError> {
172    let txt = txt.replace(['\r', '\t'], "");
173    let txt = txt.trim();
174    if txt.is_empty() {
175        return Err(FiscalError::WrongDocument("Empty document".into()));
176    }
177
178    let lines: Vec<&str> = txt.lines().collect();
179    let first_fields: Vec<&str> = lines[0].split('|').collect();
180    if first_fields[0] != "NOTAFISCAL" {
181        return Err(FiscalError::WrongDocument(
182            "Wrong document: not a valid NFe TXT".into(),
183        ));
184    }
185
186    let rest: Vec<&str> = lines[1..].to_vec();
187    let norm_layout = normalize_layout(layout);
188    let errors = validate_txt_lines(&rest, &norm_layout);
189
190    if errors.is_empty() {
191        Ok(true)
192    } else {
193        Ok(false)
194    }
195}
196
197// ── Helpers ─────────────────────────────────────────────────────────────────
198
199fn normalize_layout(layout: &str) -> String {
200    let up = layout.to_uppercase();
201    match up.as_str() {
202        "LOCAL" | "LOCAL_V12" | "LOCAL_V13" | "SEBRAE" => up,
203        _ => "LOCAL_V12".to_string(),
204    }
205}
206
207fn slice_invoices<'a>(rest: &[&'a str], declared: usize) -> Vec<Vec<&'a str>> {
208    if declared <= 1 {
209        return vec![rest.to_vec()];
210    }
211
212    let mut markers: Vec<(usize, usize)> = Vec::new();
213    for (i, line) in rest.iter().enumerate() {
214        if line.starts_with("A|") {
215            if let Some(last) = markers.last_mut() {
216                last.1 = i;
217            }
218            markers.push((i, 0));
219        }
220    }
221    if let Some(last) = markers.last_mut() {
222        last.1 = rest.len();
223    }
224
225    markers.iter().map(|(s, e)| rest[*s..*e].to_vec()).collect()
226}
227
228fn extract_layout_version(invoice: &[&str]) -> Result<String, FiscalError> {
229    for line in invoice {
230        let fields: Vec<&str> = line.split('|').collect();
231        if fields[0] == "A" {
232            return Ok(fields.get(1).unwrap_or(&"4.00").to_string());
233        }
234    }
235    Err(FiscalError::InvalidTxt(
236        "No 'A' entity found in invoice".into(),
237    ))
238}