Skip to main content

fiscal_core/
complement.rs

1use crate::FiscalError;
2use crate::constants::NFE_NAMESPACE;
3use crate::status_codes::{VALID_PROTOCOL_STATUSES, sefaz_status};
4use crate::xml_utils::extract_xml_tag_value;
5
6/// NF-e version used in wrapper elements when no version is found.
7const DEFAULT_VERSION: &str = "4.00";
8
9// ── Public API ──────────────────────────────────────────────────────────────
10
11/// Attach the SEFAZ authorization protocol to a signed NFe XML,
12/// producing the `<nfeProc>` wrapper required for storage and DANFE.
13///
14/// The function extracts the `<NFe>` from `request_xml` and the matching
15/// `<protNFe>` from `response_xml`, validates the protocol status, and
16/// joins them into a single `<nfeProc>` document.
17///
18/// If the response contains multiple `<protNFe>` nodes (batch response),
19/// the function attempts to match by digest value and access key. When no
20/// exact match is found it falls back to the first available `<protNFe>`.
21///
22/// # Errors
23///
24/// Returns [`FiscalError::XmlParsing`] if:
25/// - Either input is empty
26/// - The `<NFe>` tag is missing from `request_xml`
27/// - No `<protNFe>` can be found in `response_xml`
28///
29/// Returns [`FiscalError::SefazRejection`] if the protocol status code
30/// is not in [`VALID_PROTOCOL_STATUSES`].
31pub fn attach_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
32    if request_xml.is_empty() {
33        return Err(FiscalError::XmlParsing("Request XML (NFe) is empty".into()));
34    }
35    if response_xml.is_empty() {
36        return Err(FiscalError::XmlParsing(
37            "Response XML (protocol) is empty".into(),
38        ));
39    }
40
41    let nfe_content = extract_tag(request_xml, "NFe")
42        .ok_or_else(|| FiscalError::XmlParsing("Could not find <NFe> tag in request XML".into()))?;
43
44    // Extract digest and access key from the NFe for matching
45    let digest_nfe = extract_xml_tag_value(request_xml, "DigestValue");
46    let access_key = extract_inf_nfe_id(request_xml);
47
48    // Try to find a matching protNFe by digest + access key
49    let mut matched_prot: Option<String> = None;
50
51    let prot_nodes = extract_all_tags(response_xml, "protNFe");
52
53    for prot in &prot_nodes {
54        let dig_val = extract_xml_tag_value(prot, "digVal");
55        let ch_nfe = extract_xml_tag_value(prot, "chNFe");
56
57        if let (Some(dn), Some(dv)) = (&digest_nfe, &dig_val) {
58            if let (Some(ak), Some(cn)) = (&access_key, &ch_nfe) {
59                if dn == dv && ak == cn {
60                    // Exact match — validate status
61                    let c_stat = extract_xml_tag_value(prot, "cStat").unwrap_or_default();
62                    if !VALID_PROTOCOL_STATUSES.contains(&c_stat.as_str()) {
63                        let x_motivo = extract_xml_tag_value(prot, "xMotivo").unwrap_or_default();
64                        return Err(FiscalError::SefazRejection {
65                            code: c_stat,
66                            message: x_motivo,
67                        });
68                    }
69                    matched_prot = Some(prot.clone());
70                    break;
71                }
72            }
73        }
74    }
75
76    if matched_prot.is_none() {
77        // Check if any protNFe had a digVal (but didn't match)
78        let mut found_dig_val = false;
79        for prot in &prot_nodes {
80            if extract_xml_tag_value(prot, "digVal").is_some() {
81                found_dig_val = true;
82                break;
83            }
84        }
85
86        if !prot_nodes.is_empty() && !found_dig_val {
87            // digVal is null in the response — error 18 per PHP
88            let first_prot = &prot_nodes[0];
89            let c_stat = extract_xml_tag_value(first_prot, "cStat").unwrap_or_default();
90            let x_motivo = extract_xml_tag_value(first_prot, "xMotivo").unwrap_or_default();
91            let msg = format!("digVal ausente na resposta SEFAZ: [{c_stat}] {x_motivo}");
92            return Err(FiscalError::SefazRejection {
93                code: c_stat,
94                message: msg,
95            });
96        }
97
98        if found_dig_val {
99            // digVal exists but didn't match our DigestValue — error 5 per PHP
100            let key_info = access_key.as_deref().unwrap_or("unknown");
101            return Err(FiscalError::XmlParsing(format!(
102                "Os digest são diferentes [{key_info}]"
103            )));
104        }
105
106        // No protNFe at all
107        let single_prot = extract_tag(response_xml, "protNFe").ok_or_else(|| {
108            FiscalError::XmlParsing("Could not find <protNFe> in response XML".into())
109        })?;
110
111        // Validate status on the fallback protNFe
112        let c_stat = extract_xml_tag_value(&single_prot, "cStat").unwrap_or_default();
113        if !VALID_PROTOCOL_STATUSES.contains(&c_stat.as_str()) {
114            let x_motivo = extract_xml_tag_value(&single_prot, "xMotivo").unwrap_or_default();
115            return Err(FiscalError::SefazRejection {
116                code: c_stat,
117                message: x_motivo,
118            });
119        }
120        matched_prot = Some(single_prot);
121    }
122
123    let version = extract_attribute(&nfe_content, "infNFe", "versao")
124        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
125
126    Ok(join_xml(
127        &nfe_content,
128        &matched_prot.unwrap(),
129        "nfeProc",
130        &version,
131    ))
132}
133
134/// Attach the SEFAZ inutilizacao response to the request,
135/// producing the `<ProcInutNFe>` wrapper.
136///
137/// Extracts `<inutNFe>` from `request_xml` and `<retInutNFe>` from
138/// `response_xml`, validates that the response status is `102` (voided),
139/// and joins them into a `<ProcInutNFe>` document.
140///
141/// # Errors
142///
143/// Returns [`FiscalError::XmlParsing`] if:
144/// - Either input is empty
145/// - The `<inutNFe>` tag is missing from `request_xml`
146/// - The `<retInutNFe>` tag is missing from `response_xml`
147///
148/// Returns [`FiscalError::SefazRejection`] if the response status is not `102`.
149pub fn attach_inutilizacao(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
150    if request_xml.is_empty() {
151        return Err(FiscalError::XmlParsing(
152            "Inutilizacao request XML is empty".into(),
153        ));
154    }
155    if response_xml.is_empty() {
156        return Err(FiscalError::XmlParsing(
157            "Inutilizacao response XML is empty".into(),
158        ));
159    }
160
161    let inut_content = extract_tag(request_xml, "inutNFe").ok_or_else(|| {
162        FiscalError::XmlParsing("Could not find <inutNFe> tag in request XML".into())
163    })?;
164
165    let ret_inut_content = extract_tag(response_xml, "retInutNFe").ok_or_else(|| {
166        FiscalError::XmlParsing("Could not find <retInutNFe> tag in response XML".into())
167    })?;
168
169    // Validate the response status — must be 102 (voided)
170    let c_stat = extract_xml_tag_value(&ret_inut_content, "cStat").unwrap_or_default();
171    if c_stat != sefaz_status::VOIDED {
172        let x_motivo = extract_xml_tag_value(&ret_inut_content, "xMotivo").unwrap_or_default();
173        return Err(FiscalError::SefazRejection {
174            code: c_stat,
175            message: x_motivo,
176        });
177    }
178
179    // Get version from the inutNFe request tag
180    let version = extract_attribute(&inut_content, "inutNFe", "versao")
181        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
182
183    // Cross-validate request vs response fields (like PHP addInutNFeProtocol)
184    let ret_version = extract_attribute(&ret_inut_content, "retInutNFe", "versao")
185        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
186
187    // Determine whether the request uses CNPJ or CPF
188    let cpf_or_cnpj_tag = if extract_xml_tag_value(&inut_content, "CNPJ").is_some() {
189        "CNPJ"
190    } else {
191        "CPF"
192    };
193
194    let field_pairs: &[(&str, &str, &str)] = &[("versao", &version, &ret_version)];
195    for &(name, req_val, ret_val) in field_pairs {
196        if req_val != ret_val {
197            return Err(FiscalError::XmlParsing(format!(
198                "Inutilização: {name} diverge entre request ({req_val}) e response ({ret_val})"
199            )));
200        }
201    }
202
203    let tag_pairs: &[&str] = &[
204        "tpAmb",
205        "cUF",
206        "ano",
207        cpf_or_cnpj_tag,
208        "mod",
209        "serie",
210        "nNFIni",
211        "nNFFin",
212    ];
213    for tag_name in tag_pairs {
214        let req_val = extract_xml_tag_value(&inut_content, tag_name).unwrap_or_default();
215        let ret_val = extract_xml_tag_value(&ret_inut_content, tag_name).unwrap_or_default();
216        if req_val != ret_val {
217            return Err(FiscalError::XmlParsing(format!(
218                "Inutilização: <{tag_name}> diverge entre request ({req_val}) e response ({ret_val})"
219            )));
220        }
221    }
222
223    Ok(join_xml(
224        &inut_content,
225        &ret_inut_content,
226        "ProcInutNFe",
227        &version,
228    ))
229}
230
231/// Attach an event protocol response to the event request,
232/// producing the `<procEventoNFe>` wrapper.
233///
234/// Extracts `<evento>` from `request_xml` and `<retEvento>` from
235/// `response_xml`, validates the event status, and joins them
236/// into a `<procEventoNFe>` document.
237///
238/// # Errors
239///
240/// Returns [`FiscalError::XmlParsing`] if:
241/// - Either input is empty
242/// - The `<evento>` tag is missing from `request_xml`
243/// - The `<retEvento>` tag is missing from `response_xml`
244///
245/// Returns [`FiscalError::SefazRejection`] if the event status code
246/// is not valid (135, 136, or 155 for cancellation only).
247///
248/// Returns [`FiscalError::XmlParsing`] if the `idLote` values differ
249/// between request and response.
250pub fn attach_event_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
251    if request_xml.is_empty() {
252        return Err(FiscalError::XmlParsing("Event request XML is empty".into()));
253    }
254    if response_xml.is_empty() {
255        return Err(FiscalError::XmlParsing(
256            "Event response XML is empty".into(),
257        ));
258    }
259
260    let evento_content = extract_tag(request_xml, "evento").ok_or_else(|| {
261        FiscalError::XmlParsing("Could not find <evento> tag in request XML".into())
262    })?;
263
264    let ret_evento_content = extract_tag(response_xml, "retEvento").ok_or_else(|| {
265        FiscalError::XmlParsing("Could not find <retEvento> tag in response XML".into())
266    })?;
267
268    // Get version from the evento tag
269    let version = extract_attribute(&evento_content, "evento", "versao")
270        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
271
272    // Validate idLote matches between request and response (PHP addEnvEventoProtocol)
273    let req_id_lote = extract_xml_tag_value(request_xml, "idLote");
274    let ret_id_lote = extract_xml_tag_value(response_xml, "idLote");
275    if let (Some(req_lote), Some(ret_lote)) = (&req_id_lote, &ret_id_lote) {
276        if req_lote != ret_lote {
277            return Err(FiscalError::XmlParsing(
278                "Os números de lote dos documentos são diferentes".into(),
279            ));
280        }
281    }
282
283    // Validate event status
284    let c_stat = extract_xml_tag_value(&ret_evento_content, "cStat").unwrap_or_default();
285    let tp_evento = extract_xml_tag_value(&ret_evento_content, "tpEvento").unwrap_or_default();
286
287    // Build the valid statuses list: 135, 136 always; 155 only for cancellation
288    let mut valid_statuses: Vec<&str> = vec!["135", "136"];
289    if tp_evento == EVT_CANCELA {
290        valid_statuses.push("155");
291    }
292
293    if !valid_statuses.contains(&c_stat.as_str()) {
294        let x_motivo = extract_xml_tag_value(&ret_evento_content, "xMotivo").unwrap_or_default();
295        return Err(FiscalError::SefazRejection {
296            code: c_stat,
297            message: x_motivo,
298        });
299    }
300
301    Ok(join_xml(
302        &evento_content,
303        &ret_evento_content,
304        "procEventoNFe",
305        &version,
306    ))
307}
308
309/// Attach a B2B financial tag to an authorized `<nfeProc>` XML,
310/// wrapping both in a `<nfeProcB2B>` element.
311///
312/// # Arguments
313///
314/// * `nfe_proc_xml` - The authorized nfeProc XML.
315/// * `b2b_xml` - The B2B financial XML (must contain the `tag_b2b` element).
316/// * `tag_b2b` - Optional B2B tag name; defaults to `"NFeB2BFin"`.
317///
318/// # Errors
319///
320/// Returns [`FiscalError::XmlParsing`] if:
321/// - The `nfe_proc_xml` does not contain `<nfeProc>`
322/// - The `b2b_xml` does not contain the expected B2B tag
323/// - Either tag cannot be extracted
324pub fn attach_b2b(
325    nfe_proc_xml: &str,
326    b2b_xml: &str,
327    tag_b2b: Option<&str>,
328) -> Result<String, FiscalError> {
329    let tag_name = tag_b2b.unwrap_or("NFeB2BFin");
330
331    if !nfe_proc_xml.contains("<nfeProc") {
332        return Err(FiscalError::XmlParsing(
333            "XML does not contain <nfeProc> — is this an authorized NFe?".into(),
334        ));
335    }
336
337    let open_check = format!("<{tag_name}");
338    if !b2b_xml.contains(&open_check) {
339        return Err(FiscalError::XmlParsing(format!(
340            "B2B XML does not contain <{tag_name}> tag"
341        )));
342    }
343
344    let nfe_proc_content = extract_tag(nfe_proc_xml, "nfeProc")
345        .ok_or_else(|| FiscalError::XmlParsing("Could not extract <nfeProc> from XML".into()))?;
346
347    let b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
348        FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
349    })?;
350
351    let raw = format!(
352        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
353         <nfeProcB2B>{nfe_proc_content}{b2b_content}</nfeProcB2B>"
354    );
355
356    // PHP Complements::b2bTag line 79 does:
357    //   str_replace(array("\n", "\r", "\s"), '', $nfeb2bXML)
358    // This removes newlines/carriage-returns (and the literal "\s" which is
359    // a PHP quirk — "\s" inside single quotes is just the characters \ and s,
360    // but that string never appears in XML anyway).
361    let cleaned = strip_newlines(&raw);
362    Ok(cleaned)
363}
364
365/// Remove `\n` and `\r` characters from a string.
366///
367/// Mirrors the PHP `str_replace(array("\n", "\r", "\s"), '', ...)` call
368/// in `Complements::b2bTag`. The `\s` in PHP single-quoted strings is the
369/// literal two-character sequence `\s`, not a regex; we replicate by also
370/// removing it just in case, though it should never appear in valid XML.
371fn strip_newlines(s: &str) -> String {
372    let mut result = String::with_capacity(s.len());
373    let mut chars = s.chars().peekable();
374    while let Some(c) = chars.next() {
375        if c == '\n' || c == '\r' {
376            continue;
377        }
378        if c == '\\' {
379            if let Some(&'s') = chars.peek() {
380                chars.next(); // consume the 's'
381                continue;
382            }
383        }
384        result.push(c);
385    }
386    result
387}
388
389// ── Unified routing (mirrors PHP Complements::toAuthorize) ──────────────────
390
391/// Detect the document type from raw XML and dispatch to the correct
392/// protocol-attachment function.
393///
394/// This mirrors the PHP `Complements::toAuthorize()` method, which uses
395/// `Standardize::whichIs()` internally. The detection logic checks for
396/// the same root tags in the same priority order as the PHP implementation:
397///
398/// | Detected tag    | Dispatches to                  |
399/// |-----------------|-------------------------------|
400/// | `NFe`           | [`attach_protocol`]           |
401/// | `envEvento`     | [`attach_event_protocol`]     |
402/// | `inutNFe`       | [`attach_inutilizacao`]       |
403///
404/// # Errors
405///
406/// Returns [`FiscalError::XmlParsing`] if:
407/// - Either input is empty
408/// - The request XML does not match any of the known document types
409/// - The delegated function returns an error
410pub fn to_authorize(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
411    if request_xml.is_empty() {
412        return Err(FiscalError::XmlParsing(
413            "Erro ao protocolar: o XML a protocolar está vazio.".into(),
414        ));
415    }
416    if response_xml.is_empty() {
417        return Err(FiscalError::XmlParsing(
418            "Erro ao protocolar: o retorno da SEFAZ está vazio.".into(),
419        ));
420    }
421
422    // Detect using the same tag order as PHP Standardize::whichIs() + the
423    // ucfirst() / if-check in toAuthorize().
424    // PHP checks: whichIs() returns the root tag name from rootTagList,
425    // then toAuthorize() accepts only "NFe", "EnvEvento", "InutNFe".
426    // We search for these tags in the XML content:
427    if contains_xml_tag(request_xml, "NFe") {
428        attach_protocol(request_xml, response_xml)
429    } else if contains_xml_tag(request_xml, "envEvento") {
430        attach_event_protocol(request_xml, response_xml)
431    } else if contains_xml_tag(request_xml, "inutNFe") {
432        attach_inutilizacao(request_xml, response_xml)
433    } else {
434        Err(FiscalError::XmlParsing(
435            "Tipo de documento não reconhecido para protocolação".into(),
436        ))
437    }
438}
439
440/// Check if an XML string contains a given tag (with proper delimiter check).
441fn contains_xml_tag(xml: &str, tag_name: &str) -> bool {
442    let pattern = format!("<{tag_name}");
443    for (i, _) in xml.match_indices(&pattern) {
444        let after = i + pattern.len();
445        if after >= xml.len() {
446            return true;
447        }
448        let next = xml.as_bytes()[after];
449        if next == b' '
450            || next == b'>'
451            || next == b'/'
452            || next == b'\n'
453            || next == b'\r'
454            || next == b'\t'
455        {
456            return true;
457        }
458    }
459    false
460}
461
462/// Cancellation event type code (`110111`).
463const EVT_CANCELA: &str = "110111";
464/// Cancellation by substitution event type code (`110112`).
465const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
466
467/// Valid status codes for cancellation event matching.
468///
469/// - `135` — Event registered and linked
470/// - `136` — Event registered but not linked
471/// - `155` — Already cancelled (late)
472const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
473
474/// Attach a cancellation event response to an authorized `<nfeProc>` XML,
475/// marking the NF-e as locally cancelled.
476///
477/// This mirrors the PHP `Complements::cancelRegister()` method. The function
478/// searches the `cancel_event_xml` for `<retEvento>` elements whose:
479/// - `cStat` is in `[135, 136, 155]` (valid cancellation statuses)
480/// - `tpEvento` is `110111` (cancellation) or `110112` (cancellation by substitution)
481/// - `chNFe` matches the access key in the authorized NF-e's `<protNFe>`
482///
483/// When a matching `<retEvento>` is found, it is appended inside the
484/// `<nfeProc>` element (before the closing `</nfeProc>` tag).
485///
486/// If no matching cancellation event is found, the original NF-e XML is
487/// returned unchanged (same behavior as the PHP implementation).
488///
489/// # Arguments
490///
491/// * `nfe_proc_xml` - The authorized NF-e XML containing `<nfeProc>` with `<protNFe>`.
492/// * `cancel_event_xml` - The SEFAZ cancellation event response XML containing `<retEvento>`.
493///
494/// # Errors
495///
496/// Returns [`FiscalError::XmlParsing`] if:
497/// - The `nfe_proc_xml` does not contain `<protNFe>` (not an authorized NF-e)
498/// - The `<protNFe>` does not contain `<chNFe>`
499pub fn attach_cancellation(
500    nfe_proc_xml: &str,
501    cancel_event_xml: &str,
502) -> Result<String, FiscalError> {
503    // Validate the NF-e has a protNFe with a chNFe
504    let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
505        FiscalError::XmlParsing(
506            "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
507        )
508    })?;
509
510    let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
511        .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
512
513    // Search for matching retEvento in the cancellation XML
514    let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
515
516    for ret_evento in &ret_eventos {
517        let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
518            Some(v) => v,
519            None => continue,
520        };
521        let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
522            Some(v) => v,
523            None => continue,
524        };
525        let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
526            Some(v) => v,
527            None => continue,
528        };
529
530        if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
531            && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
532            && ch_nfe_evento == ch_nfe
533        {
534            // Insert the retEvento before </nfeProc>
535            let close_tag = "</nfeProc>";
536            if let Some(pos) = nfe_proc_xml.rfind(close_tag) {
537                let mut result = String::with_capacity(nfe_proc_xml.len() + ret_evento.len());
538                result.push_str(&nfe_proc_xml[..pos]);
539                result.push_str(ret_evento);
540                result.push_str(close_tag);
541                return Ok(result);
542            }
543            // If no </nfeProc>, just append to the end (best effort)
544            break;
545        }
546    }
547
548    // No matching cancellation event found — return original XML unchanged
549    Ok(nfe_proc_xml.to_string())
550}
551
552// ── Internal helpers ────────────────────────────────────────────────────────
553
554/// Join two XML fragments into a versioned namespace wrapper element.
555///
556/// Produces:
557/// ```xml
558/// <?xml version="1.0" encoding="UTF-8"?>
559/// <{node_name} versao="{version}" xmlns="{NFE_NAMESPACE}">
560///   {first}{second}
561/// </{node_name}>
562/// ```
563fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
564    format!(
565        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
566         <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
567         {first}{second}</{node_name}>"
568    )
569}
570
571/// Extract a complete XML tag (outermost match) including attributes and
572/// all nested content. Uses `lastIndexOf`-style search for the closing tag
573/// to handle nested tags of the same name.
574///
575/// Returns `None` if either the opening or closing tag is not found.
576fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
577    // Find the opening tag: <tagName followed by whitespace, >, or /
578    let open_pattern = format!("<{tag_name}");
579    let start = xml.find(&open_pattern)?;
580
581    // Verify that the character after `<tagName` is a valid delimiter
582    // (space, >, /) to avoid matching tags like `<tagNameExtra>`
583    let after_open = start + open_pattern.len();
584    if after_open < xml.len() {
585        let next_char = xml.as_bytes()[after_open];
586        if next_char != b' '
587            && next_char != b'>'
588            && next_char != b'/'
589            && next_char != b'\n'
590            && next_char != b'\r'
591            && next_char != b'\t'
592        {
593            return None;
594        }
595    }
596
597    let close_tag = format!("</{tag_name}>");
598    let close_index = xml.rfind(&close_tag)?;
599
600    Some(xml[start..close_index + close_tag.len()].to_string())
601}
602
603/// Extract all occurrences of a tag from XML. Finds each non-overlapping
604/// `<tagName ...>...</tagName>` in the source string.
605fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
606    let mut results = Vec::new();
607    let open_pattern = format!("<{tag_name}");
608    let close_tag = format!("</{tag_name}>");
609    let mut search_from = 0;
610
611    while search_from < xml.len() {
612        let start = match xml[search_from..].find(&open_pattern) {
613            Some(pos) => search_from + pos,
614            None => break,
615        };
616
617        // Verify delimiter after tag name
618        let after_open = start + open_pattern.len();
619        if after_open < xml.len() {
620            let next_char = xml.as_bytes()[after_open];
621            if next_char != b' '
622                && next_char != b'>'
623                && next_char != b'/'
624                && next_char != b'\n'
625                && next_char != b'\r'
626                && next_char != b'\t'
627            {
628                search_from = after_open;
629                continue;
630            }
631        }
632
633        let end = match xml[start..].find(&close_tag) {
634            Some(pos) => start + pos + close_tag.len(),
635            None => break,
636        };
637
638        results.push(xml[start..end].to_string());
639        search_from = end;
640    }
641
642    results
643}
644
645/// Extract an XML attribute value from a tag. Searches for the tag opening
646/// then finds `attr="value"` within it.
647fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
648    let open = format!("<{tag_name}");
649    let start = xml.find(&open)?;
650
651    // Find the end of the opening tag
652    let tag_end = xml[start..].find('>')? + start;
653    let tag_header = &xml[start..tag_end];
654
655    // Find attr="value" pattern
656    let attr_pattern = format!("{attr_name}=\"");
657    let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
658    let attr_end = tag_header[attr_start..].find('"')? + attr_start;
659
660    Some(tag_header[attr_start..attr_end].to_string())
661}
662
663/// Extract the access key from an `<infNFe Id="NFe...">` attribute.
664/// Returns the 44-digit key (without the "NFe" prefix).
665fn extract_inf_nfe_id(xml: &str) -> Option<String> {
666    let attr_val = extract_attribute(xml, "infNFe", "Id")?;
667    Some(
668        attr_val
669            .strip_prefix("NFe")
670            .unwrap_or(&attr_val)
671            .to_string(),
672    )
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn extract_tag_finds_outermost_match() {
681        let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
682        let result = extract_tag(xml, "NFe").unwrap();
683        assert!(result.starts_with("<NFe"));
684        assert!(result.ends_with("</NFe>"));
685        assert!(result.contains("<inner/>"));
686    }
687
688    #[test]
689    fn extract_tag_returns_none_for_missing_tag() {
690        let xml = "<root><other/></root>";
691        assert!(extract_tag(xml, "NFe").is_none());
692    }
693
694    #[test]
695    fn extract_tag_does_not_match_prefix() {
696        let xml = "<root><NFeExtra>data</NFeExtra></root>";
697        assert!(extract_tag(xml, "NFe").is_none());
698    }
699
700    #[test]
701    fn extract_attribute_works() {
702        let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
703        assert_eq!(
704            extract_attribute(xml, "infNFe", "versao"),
705            Some("4.00".to_string())
706        );
707        assert_eq!(
708            extract_attribute(xml, "infNFe", "Id"),
709            Some("NFe12345".to_string())
710        );
711    }
712
713    #[test]
714    fn extract_all_tags_finds_multiple() {
715        let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
716        let items = extract_all_tags(xml, "item");
717        assert_eq!(items.len(), 3);
718        assert!(items[0].contains("1"));
719        assert!(items[2].contains("3"));
720    }
721
722    #[test]
723    fn join_xml_produces_correct_wrapper() {
724        let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
725        assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
726        assert!(result.contains("<wrapper versao=\"4.00\""));
727        assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
728        assert!(result.ends_with("</wrapper>"));
729    }
730
731    #[test]
732    fn extract_inf_nfe_id_strips_prefix() {
733        let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
734        let key = extract_inf_nfe_id(xml).unwrap();
735        assert_eq!(key, "35260112345678000199650010000000011123456780");
736    }
737
738    // ── attach_cancellation tests ─────────────────────────────────────
739
740    #[test]
741    fn attach_cancellation_appends_matching_ret_evento() {
742        let nfe_proc = concat!(
743            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
744            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
745            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
746            r#"<ide/></infNFe></NFe>"#,
747            r#"<protNFe versao="4.00"><infProt>"#,
748            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
749            r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
750            r#"</infProt></protNFe>"#,
751            r#"</nfeProc>"#
752        );
753
754        let cancel_xml = concat!(
755            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
756            r#"<cStat>135</cStat>"#,
757            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
758            r#"<tpEvento>110111</tpEvento>"#,
759            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
760            r#"<nProt>135220000009999</nProt>"#,
761            r#"</infEvento></retEvento></retEnvEvento>"#
762        );
763
764        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
765
766        // Must contain the retEvento inside nfeProc
767        assert!(
768            result.contains("<retEvento"),
769            "Result should contain <retEvento>"
770        );
771        assert!(
772            result.contains("<tpEvento>110111</tpEvento>"),
773            "Result should contain cancellation event type"
774        );
775        // The retEvento should appear before </nfeProc>
776        let ret_pos = result.find("<retEvento").unwrap();
777        let close_pos = result.rfind("</nfeProc>").unwrap();
778        assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
779        // Original content should be preserved
780        assert!(result.contains("<protNFe"));
781        assert!(result.contains("<NFe>"));
782    }
783
784    #[test]
785    fn attach_cancellation_ignores_non_matching_ch_nfe() {
786        let nfe_proc = concat!(
787            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
788            r#"<NFe/>"#,
789            r#"<protNFe versao="4.00"><infProt>"#,
790            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
791            r#"<cStat>100</cStat>"#,
792            r#"</infProt></protNFe>"#,
793            r#"</nfeProc>"#
794        );
795
796        let cancel_xml = concat!(
797            r#"<retEvento versao="1.00"><infEvento>"#,
798            r#"<cStat>135</cStat>"#,
799            r#"<tpEvento>110111</tpEvento>"#,
800            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
801            r#"<nProt>135220000009999</nProt>"#,
802            r#"</infEvento></retEvento>"#
803        );
804
805        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
806        // Should return original unchanged — no matching chNFe
807        assert_eq!(result, nfe_proc);
808    }
809
810    #[test]
811    fn attach_cancellation_ignores_wrong_tp_evento() {
812        let nfe_proc = concat!(
813            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
814            r#"<NFe/>"#,
815            r#"<protNFe versao="4.00"><infProt>"#,
816            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
817            r#"<cStat>100</cStat>"#,
818            r#"</infProt></protNFe>"#,
819            r#"</nfeProc>"#
820        );
821
822        let cancel_xml = concat!(
823            r#"<retEvento versao="1.00"><infEvento>"#,
824            r#"<cStat>135</cStat>"#,
825            r#"<tpEvento>110110</tpEvento>"#, // CCe, not cancellation
826            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
827            r#"<nProt>135220000009999</nProt>"#,
828            r#"</infEvento></retEvento>"#
829        );
830
831        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
832        // Should return original unchanged — wrong tpEvento
833        assert_eq!(result, nfe_proc);
834    }
835
836    #[test]
837    fn attach_cancellation_ignores_rejected_status() {
838        let nfe_proc = concat!(
839            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
840            r#"<NFe/>"#,
841            r#"<protNFe versao="4.00"><infProt>"#,
842            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
843            r#"<cStat>100</cStat>"#,
844            r#"</infProt></protNFe>"#,
845            r#"</nfeProc>"#
846        );
847
848        let cancel_xml = concat!(
849            r#"<retEvento versao="1.00"><infEvento>"#,
850            r#"<cStat>573</cStat>"#, // Rejected status
851            r#"<tpEvento>110111</tpEvento>"#,
852            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
853            r#"<nProt>135220000009999</nProt>"#,
854            r#"</infEvento></retEvento>"#
855        );
856
857        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
858        // Should return original unchanged — rejected status
859        assert_eq!(result, nfe_proc);
860    }
861
862    #[test]
863    fn attach_cancellation_accepts_status_155() {
864        let nfe_proc = concat!(
865            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
866            r#"<NFe/>"#,
867            r#"<protNFe versao="4.00"><infProt>"#,
868            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
869            r#"<cStat>100</cStat>"#,
870            r#"</infProt></protNFe>"#,
871            r#"</nfeProc>"#
872        );
873
874        let cancel_xml = concat!(
875            r#"<retEvento versao="1.00"><infEvento>"#,
876            r#"<cStat>155</cStat>"#,
877            r#"<tpEvento>110111</tpEvento>"#,
878            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
879            r#"<nProt>135220000009999</nProt>"#,
880            r#"</infEvento></retEvento>"#
881        );
882
883        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
884        assert!(result.contains("<retEvento"));
885    }
886
887    #[test]
888    fn attach_cancellation_accepts_substituicao_110112() {
889        let nfe_proc = concat!(
890            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
891            r#"<NFe/>"#,
892            r#"<protNFe versao="4.00"><infProt>"#,
893            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
894            r#"<cStat>100</cStat>"#,
895            r#"</infProt></protNFe>"#,
896            r#"</nfeProc>"#
897        );
898
899        let cancel_xml = concat!(
900            r#"<retEvento versao="1.00"><infEvento>"#,
901            r#"<cStat>135</cStat>"#,
902            r#"<tpEvento>110112</tpEvento>"#,
903            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
904            r#"<nProt>135220000009999</nProt>"#,
905            r#"</infEvento></retEvento>"#
906        );
907
908        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
909        assert!(
910            result.contains("<tpEvento>110112</tpEvento>"),
911            "Should accept cancellation by substitution"
912        );
913    }
914
915    #[test]
916    fn attach_cancellation_rejects_missing_prot_nfe() {
917        let nfe_xml = "<NFe><infNFe/></NFe>";
918        let cancel_xml = "<retEvento/>";
919        let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
920        assert!(matches!(err, FiscalError::XmlParsing(_)));
921    }
922
923    #[test]
924    fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
925        let nfe_proc = concat!(
926            r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
927            r#"<cStat>100</cStat>"#,
928            r#"</infProt></protNFe></nfeProc>"#
929        );
930        let cancel_xml = "<retEvento/>";
931        let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
932        assert!(matches!(err, FiscalError::XmlParsing(_)));
933    }
934
935    // ── attach_protocol tests ─────────────────────────────────────
936
937    #[test]
938    fn attach_protocol_empty_request_xml() {
939        let err = attach_protocol("", "<protNFe/>").unwrap_err();
940        assert!(matches!(err, FiscalError::XmlParsing(_)));
941    }
942
943    #[test]
944    fn attach_protocol_empty_response_xml() {
945        let err = attach_protocol("<NFe/>", "").unwrap_err();
946        assert!(matches!(err, FiscalError::XmlParsing(_)));
947    }
948
949    #[test]
950    fn attach_protocol_matching_digest_and_key() {
951        let request = concat!(
952            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
953            r#"<ide/></infNFe>"#,
954            r#"<Signature><SignedInfo/><SignatureValue/>"#,
955            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
956            r#"</NFe>"#
957        );
958        let response = concat!(
959            r#"<protNFe versao="4.00"><infProt>"#,
960            r#"<digVal>abc123</digVal>"#,
961            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
962            r#"<cStat>100</cStat>"#,
963            r#"<xMotivo>Autorizado</xMotivo>"#,
964            r#"</infProt></protNFe>"#
965        );
966        let result = attach_protocol(request, response).unwrap();
967        assert!(result.contains("<nfeProc"));
968        assert!(result.contains("</nfeProc>"));
969        assert!(result.contains("<NFe>"));
970        assert!(result.contains("<protNFe"));
971    }
972
973    #[test]
974    fn attach_protocol_rejected_status_in_exact_match() {
975        let request = concat!(
976            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
977            r#"<ide/></infNFe>"#,
978            r#"<Signature><SignedInfo/><SignatureValue/>"#,
979            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
980            r#"</NFe>"#
981        );
982        let response = concat!(
983            r#"<protNFe versao="4.00"><infProt>"#,
984            r#"<digVal>abc123</digVal>"#,
985            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
986            r#"<cStat>999</cStat>"#,
987            r#"<xMotivo>Rejeitada</xMotivo>"#,
988            r#"</infProt></protNFe>"#
989        );
990        let err = attach_protocol(request, response).unwrap_err();
991        assert!(matches!(err, FiscalError::SefazRejection { .. }));
992    }
993
994    #[test]
995    fn attach_protocol_fallback_rejected_status() {
996        // No digest match, falls back to first protNFe which is rejected
997        let request = concat!(
998            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
999            r#"<ide/></infNFe></NFe>"#
1000        );
1001        let response = concat!(
1002            r#"<protNFe versao="4.00"><infProt>"#,
1003            r#"<cStat>999</cStat>"#,
1004            r#"<xMotivo>Rejeitada</xMotivo>"#,
1005            r#"</infProt></protNFe>"#
1006        );
1007        let err = attach_protocol(request, response).unwrap_err();
1008        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1009    }
1010
1011    // ── attach_inutilizacao tests ───────────────────────────────────
1012
1013    #[test]
1014    fn attach_inutilizacao_empty_request() {
1015        let err = attach_inutilizacao("", "<retInutNFe/>").unwrap_err();
1016        assert!(matches!(err, FiscalError::XmlParsing(_)));
1017    }
1018
1019    #[test]
1020    fn attach_inutilizacao_empty_response() {
1021        let err = attach_inutilizacao("<inutNFe/>", "").unwrap_err();
1022        assert!(matches!(err, FiscalError::XmlParsing(_)));
1023    }
1024
1025    #[test]
1026    fn attach_inutilizacao_missing_inut_tag() {
1027        let err = attach_inutilizacao("<other/>", "<retInutNFe><cStat>102</cStat></retInutNFe>")
1028            .unwrap_err();
1029        assert!(matches!(err, FiscalError::XmlParsing(_)));
1030    }
1031
1032    #[test]
1033    fn attach_inutilizacao_missing_ret_tag() {
1034        let err = attach_inutilizacao(r#"<inutNFe versao="4.00"><data/></inutNFe>"#, "<other/>")
1035            .unwrap_err();
1036        assert!(matches!(err, FiscalError::XmlParsing(_)));
1037    }
1038
1039    #[test]
1040    fn attach_inutilizacao_rejected_status() {
1041        let err = attach_inutilizacao(
1042            r#"<inutNFe versao="4.00"><data/></inutNFe>"#,
1043            r#"<retInutNFe><cStat>999</cStat><xMotivo>Erro</xMotivo></retInutNFe>"#,
1044        )
1045        .unwrap_err();
1046        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1047    }
1048
1049    #[test]
1050    fn attach_inutilizacao_success() {
1051        let result = attach_inutilizacao(
1052            r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#,
1053            r#"<retInutNFe><cStat>102</cStat><xMotivo>Inutilizacao de numero homologado</xMotivo></retInutNFe>"#,
1054        )
1055        .unwrap();
1056        assert!(result.contains("<ProcInutNFe"));
1057        assert!(result.contains("<inutNFe"));
1058        assert!(result.contains("<retInutNFe>"));
1059    }
1060
1061    // ── attach_event_protocol tests ─────────────────────────────────
1062
1063    #[test]
1064    fn attach_event_protocol_empty_request() {
1065        let err = attach_event_protocol("", "<retEvento/>").unwrap_err();
1066        assert!(matches!(err, FiscalError::XmlParsing(_)));
1067    }
1068
1069    #[test]
1070    fn attach_event_protocol_empty_response() {
1071        let err = attach_event_protocol("<evento/>", "").unwrap_err();
1072        assert!(matches!(err, FiscalError::XmlParsing(_)));
1073    }
1074
1075    #[test]
1076    fn attach_event_protocol_missing_evento() {
1077        let err = attach_event_protocol(
1078            "<other/>",
1079            "<retEvento><infEvento><cStat>135</cStat></infEvento></retEvento>",
1080        )
1081        .unwrap_err();
1082        assert!(matches!(err, FiscalError::XmlParsing(_)));
1083    }
1084
1085    #[test]
1086    fn attach_event_protocol_missing_ret_evento() {
1087        let err =
1088            attach_event_protocol(r#"<evento versao="1.00"><infEvento/></evento>"#, "<other/>")
1089                .unwrap_err();
1090        assert!(matches!(err, FiscalError::XmlParsing(_)));
1091    }
1092
1093    #[test]
1094    fn attach_event_protocol_rejected_status() {
1095        let err = attach_event_protocol(
1096            r#"<evento versao="1.00"><infEvento/></evento>"#,
1097            r#"<retEvento><infEvento><cStat>999</cStat><xMotivo>Rejeitado</xMotivo></infEvento></retEvento>"#,
1098        )
1099        .unwrap_err();
1100        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1101    }
1102
1103    #[test]
1104    fn attach_event_protocol_success() {
1105        let result = attach_event_protocol(
1106            r#"<evento versao="1.00"><infEvento Id="ID1234"/></evento>"#,
1107            r#"<retEvento><infEvento><cStat>135</cStat><xMotivo>Evento registrado</xMotivo></infEvento></retEvento>"#,
1108        )
1109        .unwrap();
1110        assert!(result.contains("<procEventoNFe"));
1111        assert!(result.contains("<evento"));
1112        assert!(result.contains("<retEvento>"));
1113    }
1114
1115    // ── attach_b2b tests ────────────────────────────────────────────
1116
1117    #[test]
1118    fn attach_b2b_no_nfe_proc() {
1119        let err = attach_b2b("<NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1120        assert!(matches!(err, FiscalError::XmlParsing(_)));
1121    }
1122
1123    #[test]
1124    fn attach_b2b_no_b2b_tag() {
1125        let err = attach_b2b("<nfeProc><NFe/></nfeProc>", "<other>data</other>", None).unwrap_err();
1126        assert!(matches!(err, FiscalError::XmlParsing(_)));
1127    }
1128
1129    #[test]
1130    fn attach_b2b_extract_failure() {
1131        // nfeProc without closing tag won't extract
1132        let err = attach_b2b("<nfeProc><NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1133        assert!(matches!(err, FiscalError::XmlParsing(_)));
1134    }
1135
1136    #[test]
1137    fn attach_b2b_success() {
1138        let result = attach_b2b(
1139            "<nfeProc><NFe/><protNFe/></nfeProc>",
1140            "<NFeB2BFin><tag>data</tag></NFeB2BFin>",
1141            None,
1142        )
1143        .unwrap();
1144        assert!(result.contains("<nfeProcB2B>"));
1145        assert!(result.contains("<nfeProc>"));
1146        assert!(result.contains("<NFeB2BFin>"));
1147    }
1148
1149    #[test]
1150    fn attach_b2b_custom_tag() {
1151        let result = attach_b2b(
1152            "<nfeProc><NFe/><protNFe/></nfeProc>",
1153            "<CustomB2B><tag>data</tag></CustomB2B>",
1154            Some("CustomB2B"),
1155        )
1156        .unwrap();
1157        assert!(result.contains("<CustomB2B>"));
1158    }
1159
1160    // ── extract_all_tags delimiter check ─────────────────────────────
1161
1162    #[test]
1163    fn extract_all_tags_skips_prefix_match() {
1164        // "protNFeExtra" should NOT be matched when looking for "protNFe"
1165        let xml = "<root><protNFeExtra>bad</protNFeExtra><protNFe>good</protNFe></root>";
1166        let results = extract_all_tags(xml, "protNFe");
1167        assert_eq!(results.len(), 1);
1168        assert!(results[0].contains("good"));
1169    }
1170
1171    #[test]
1172    fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
1173        let nfe_proc = concat!(
1174            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1175            r#"<NFe/>"#,
1176            r#"<protNFe versao="4.00"><infProt>"#,
1177            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1178            r#"<cStat>100</cStat>"#,
1179            r#"</infProt></protNFe>"#,
1180            r#"</nfeProc>"#
1181        );
1182
1183        let cancel_xml = concat!(
1184            r#"<retEnvEvento>"#,
1185            // First: wrong chNFe
1186            r#"<retEvento versao="1.00"><infEvento>"#,
1187            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1188            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1189            r#"<nProt>111111111111111</nProt>"#,
1190            r#"</infEvento></retEvento>"#,
1191            // Second: correct match
1192            r#"<retEvento versao="1.00"><infEvento>"#,
1193            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1194            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1195            r#"<nProt>222222222222222</nProt>"#,
1196            r#"</infEvento></retEvento>"#,
1197            r#"</retEnvEvento>"#
1198        );
1199
1200        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1201        assert!(result.contains("<nProt>222222222222222</nProt>"));
1202        // Should only have one retEvento (the matching one)
1203        assert_eq!(result.matches("<retEvento").count(), 1);
1204    }
1205
1206    // ── to_authorize routing tests ──────────────────────────────────────
1207
1208    #[test]
1209    fn to_authorize_empty_request_returns_error() {
1210        let err = to_authorize("", "<retEnviNFe/>").unwrap_err();
1211        assert!(matches!(err, FiscalError::XmlParsing(_)));
1212    }
1213
1214    #[test]
1215    fn to_authorize_empty_response_returns_error() {
1216        let err = to_authorize("<NFe/>", "").unwrap_err();
1217        assert!(matches!(err, FiscalError::XmlParsing(_)));
1218    }
1219
1220    #[test]
1221    fn to_authorize_unrecognized_document_returns_error() {
1222        let err = to_authorize("<other>data</other>", "<response/>").unwrap_err();
1223        let msg = format!("{err}");
1224        assert!(
1225            msg.contains("não reconhecido"),
1226            "should mention unrecognized type: {msg}"
1227        );
1228    }
1229
1230    #[test]
1231    fn contains_xml_tag_basic() {
1232        assert!(contains_xml_tag("<NFe versao=\"4.00\">", "NFe"));
1233        assert!(contains_xml_tag("<NFe>", "NFe"));
1234        assert!(contains_xml_tag("<NFe/>", "NFe"));
1235        assert!(!contains_xml_tag("<NFeExtra>", "NFe"));
1236        assert!(contains_xml_tag("<envEvento versao=\"1.00\">", "envEvento"));
1237        assert!(contains_xml_tag("<inutNFe versao=\"4.00\">", "inutNFe"));
1238    }
1239
1240    // ── attach_b2b whitespace stripping tests ───────────────────────────
1241
1242    #[test]
1243    fn attach_b2b_strips_newlines() {
1244        let nfe_proc = "<nfeProc versao=\"4.00\">\n<NFe/>\n<protNFe/>\n</nfeProc>";
1245        let b2b = "<NFeB2BFin>\n<data>test</data>\n</NFeB2BFin>";
1246        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1247        assert!(!result.contains('\n'), "Result should not contain newlines");
1248        assert!(
1249            !result.contains('\r'),
1250            "Result should not contain carriage returns"
1251        );
1252        assert!(result.contains("<nfeProcB2B>"));
1253        assert!(result.contains("<NFeB2BFin>"));
1254    }
1255
1256    #[test]
1257    fn attach_b2b_strips_carriage_returns() {
1258        let nfe_proc = "<nfeProc versao=\"4.00\">\r\n<NFe/>\r\n</nfeProc>";
1259        let b2b = "<NFeB2BFin><data>test</data></NFeB2BFin>";
1260        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1261        assert!(!result.contains('\r'));
1262        assert!(!result.contains('\n'));
1263    }
1264
1265    // ── attach_protocol: fallback protNFe with invalid cStat (lines 112-116) ──
1266
1267    #[test]
1268    fn attach_protocol_fallback_prot_invalid_status() {
1269        // Request with NFe, digest, access key
1270        let request = concat!(
1271            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1272            r#"<DigestValue>abc123</DigestValue>"#,
1273            r#"</infNFe></NFe>"#
1274        );
1275        // Response with single protNFe that has NO digVal (trigger fallback),
1276        // but status is invalid
1277        let response = concat!(
1278            r#"<protNFe versao="4.00"><infProt>"#,
1279            r#"<cStat>999</cStat>"#,
1280            r#"<xMotivo>Rejeitado</xMotivo>"#,
1281            r#"</infProt></protNFe>"#
1282        );
1283        let err = attach_protocol(request, response).unwrap_err();
1284        match err {
1285            FiscalError::SefazRejection { code, .. } => assert_eq!(code, "999"),
1286            other => panic!("Expected SefazRejection, got {:?}", other),
1287        }
1288    }
1289
1290    // ── attach_inutilizacao: version mismatch (line 197) ────────────────
1291
1292    #[test]
1293    fn attach_inutilizacao_version_mismatch() {
1294        let request = concat!(
1295            r#"<inutNFe versao="4.00"><infInut>"#,
1296            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1297            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1298            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1299            r#"</infInut></inutNFe>"#
1300        );
1301        let response = concat!(
1302            r#"<retInutNFe versao="3.10"><infInut>"#,
1303            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1304            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1305            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1306            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1307            r#"</infInut></retInutNFe>"#
1308        );
1309        let err = attach_inutilizacao(request, response).unwrap_err();
1310        match err {
1311            FiscalError::XmlParsing(msg) => {
1312                assert!(
1313                    msg.contains("versao"),
1314                    "Expected version mismatch error, got: {msg}"
1315                );
1316            }
1317            other => panic!("Expected XmlParsing, got {:?}", other),
1318        }
1319    }
1320
1321    // ── attach_inutilizacao: tag mismatch (line 217) ────────────────────
1322
1323    #[test]
1324    fn attach_inutilizacao_tag_value_mismatch() {
1325        let request = concat!(
1326            r#"<inutNFe versao="4.00"><infInut>"#,
1327            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1328            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1329            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1330            r#"</infInut></inutNFe>"#
1331        );
1332        let response = concat!(
1333            r#"<retInutNFe versao="4.00"><infInut>"#,
1334            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1335            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1336            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>2</serie>"#,
1337            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1338            r#"</infInut></retInutNFe>"#
1339        );
1340        let err = attach_inutilizacao(request, response).unwrap_err();
1341        match err {
1342            FiscalError::XmlParsing(msg) => {
1343                assert!(
1344                    msg.contains("serie"),
1345                    "Expected serie mismatch error, got: {msg}"
1346                );
1347            }
1348            other => panic!("Expected XmlParsing, got {:?}", other),
1349        }
1350    }
1351
1352    // ── attach_event_protocol: idLote mismatch (lines 277-278) ──────────
1353
1354    #[test]
1355    fn attach_event_protocol_id_lote_mismatch() {
1356        let request = concat!(
1357            r#"<envEvento><idLote>100</idLote>"#,
1358            r#"<evento versao="1.00"><infEvento>"#,
1359            r#"<tpEvento>110110</tpEvento>"#,
1360            r#"</infEvento></evento></envEvento>"#
1361        );
1362        let response = concat!(
1363            r#"<retEnvEvento><idLote>999</idLote>"#,
1364            r#"<retEvento versao="1.00"><infEvento>"#,
1365            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1366            r#"<tpEvento>110110</tpEvento>"#,
1367            r#"</infEvento></retEvento></retEnvEvento>"#
1368        );
1369        let err = attach_event_protocol(request, response).unwrap_err();
1370        match err {
1371            FiscalError::XmlParsing(msg) => {
1372                assert!(
1373                    msg.contains("lote"),
1374                    "Expected lote mismatch error, got: {msg}"
1375                );
1376            }
1377            other => panic!("Expected XmlParsing, got {:?}", other),
1378        }
1379    }
1380
1381    // ── attach_b2b: extract_tag for b2b content (line 348) ──────────────
1382
1383    #[test]
1384    fn attach_b2b_extract_tag_coverage() {
1385        let nfe_proc = concat!(
1386            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1387            r#"<NFe><infNFe/></NFe><protNFe><infProt/></protNFe>"#,
1388            r#"</nfeProc>"#
1389        );
1390        let b2b = r#"<NFeB2BFin versao="1.00"><dados>value</dados></NFeB2BFin>"#;
1391        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1392        assert!(result.contains("<nfeProcB2B>"));
1393        assert!(result.contains("<dados>value</dados>"));
1394    }
1395
1396    // ── to_authorize: NFe path (line 428) ───────────────────────────────
1397
1398    #[test]
1399    fn to_authorize_dispatches_nfe() {
1400        let request = concat!(
1401            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1402            r#"<DigestValue>abc</DigestValue>"#,
1403            r#"</infNFe></NFe>"#
1404        );
1405        let response = concat!(
1406            r#"<protNFe versao="4.00"><infProt>"#,
1407            r#"<cStat>100</cStat><xMotivo>OK</xMotivo>"#,
1408            r#"<digVal>abc</digVal>"#,
1409            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1410            r#"</infProt></protNFe>"#
1411        );
1412        let result = to_authorize(request, response).unwrap();
1413        assert!(result.contains("<nfeProc"));
1414    }
1415
1416    // ── to_authorize: envEvento path (line 430) ─────────────────────────
1417
1418    #[test]
1419    fn to_authorize_dispatches_env_evento() {
1420        let request = concat!(
1421            r#"<envEvento>"#,
1422            r#"<evento versao="1.00"><infEvento>"#,
1423            r#"<tpEvento>110110</tpEvento>"#,
1424            r#"</infEvento></evento></envEvento>"#
1425        );
1426        let response = concat!(
1427            r#"<retEvento versao="1.00"><infEvento>"#,
1428            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1429            r#"<tpEvento>110110</tpEvento>"#,
1430            r#"</infEvento></retEvento>"#
1431        );
1432        let result = to_authorize(request, response).unwrap();
1433        assert!(result.contains("<procEventoNFe"));
1434    }
1435
1436    // ── to_authorize: inutNFe path (line 432) ───────────────────────────
1437
1438    #[test]
1439    fn to_authorize_dispatches_inut_nfe() {
1440        let request = concat!(
1441            r#"<inutNFe versao="4.00"><infInut>"#,
1442            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1443            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1444            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1445            r#"</infInut></inutNFe>"#
1446        );
1447        let response = concat!(
1448            r#"<retInutNFe versao="4.00"><infInut>"#,
1449            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1450            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1451            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1452            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1453            r#"</infInut></retInutNFe>"#
1454        );
1455        let result = to_authorize(request, response).unwrap();
1456        assert!(result.contains("<ProcInutNFe"));
1457    }
1458
1459    // ── contains_xml_tag: tag at very end of string (line 446) ──────────
1460
1461    #[test]
1462    fn contains_xml_tag_at_end_of_string() {
1463        // Tag pattern at the very end, after >= xml.len() → true
1464        assert!(contains_xml_tag("<NFe", "NFe"));
1465    }
1466
1467    // ── strip_newlines helper tests ─────────────────────────────────────
1468
1469    #[test]
1470    fn strip_newlines_removes_newlines_and_cr() {
1471        assert_eq!(strip_newlines("a\nb\rc\r\nd"), "abcd");
1472    }
1473
1474    #[test]
1475    fn strip_newlines_removes_literal_backslash_s() {
1476        assert_eq!(strip_newlines("abc\\sdef"), "abcdef");
1477    }
1478
1479    #[test]
1480    fn strip_newlines_preserves_normal_content() {
1481        assert_eq!(strip_newlines("<tag>value</tag>"), "<tag>value</tag>");
1482    }
1483}