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/// - The `<idLote>` tag is missing from `request_xml` or `response_xml`
245/// - The `idLote` values differ between request and response
246///
247/// Returns [`FiscalError::SefazRejection`] if the event status code
248/// is not valid (135, 136, or 155 for cancellation only).
249pub fn attach_event_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
250    if request_xml.is_empty() {
251        return Err(FiscalError::XmlParsing("Event request XML is empty".into()));
252    }
253    if response_xml.is_empty() {
254        return Err(FiscalError::XmlParsing(
255            "Event response XML is empty".into(),
256        ));
257    }
258
259    let evento_content = extract_tag(request_xml, "evento").ok_or_else(|| {
260        FiscalError::XmlParsing("Could not find <evento> tag in request XML".into())
261    })?;
262
263    let ret_evento_content = extract_tag(response_xml, "retEvento").ok_or_else(|| {
264        FiscalError::XmlParsing("Could not find <retEvento> tag in response XML".into())
265    })?;
266
267    // Get version from the evento tag
268    let version = extract_attribute(&evento_content, "evento", "versao")
269        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
270
271    // Validate event status FIRST (PHP validates cStat before idLote)
272    let c_stat = extract_xml_tag_value(&ret_evento_content, "cStat").unwrap_or_default();
273    let tp_evento = extract_xml_tag_value(&ret_evento_content, "tpEvento").unwrap_or_default();
274
275    // Build the valid statuses list: 135, 136 always; 155 only for cancellation
276    let mut valid_statuses: Vec<&str> = vec!["135", "136"];
277    if tp_evento == EVT_CANCELA {
278        valid_statuses.push("155");
279    }
280
281    if !valid_statuses.contains(&c_stat.as_str()) {
282        let x_motivo = extract_xml_tag_value(&ret_evento_content, "xMotivo").unwrap_or_default();
283        return Err(FiscalError::SefazRejection {
284            code: c_stat,
285            message: x_motivo,
286        });
287    }
288
289    // Validate idLote is present in both request and response, then compare.
290    // PHP addEnvEventoProtocol accesses ->nodeValue directly on idLote;
291    // if the tag is absent, PHP throws a fatal error.
292    let req_id_lote = extract_xml_tag_value(request_xml, "idLote")
293        .ok_or_else(|| FiscalError::XmlParsing("idLote not found in request XML".into()))?;
294    let ret_id_lote = extract_xml_tag_value(response_xml, "idLote")
295        .ok_or_else(|| FiscalError::XmlParsing("idLote not found in response XML".into()))?;
296    if req_id_lote != ret_id_lote {
297        return Err(FiscalError::XmlParsing(
298            "Os números de lote dos documentos são diferentes".into(),
299        ));
300    }
301
302    Ok(join_xml(
303        &evento_content,
304        &ret_evento_content,
305        "procEventoNFe",
306        &version,
307    ))
308}
309
310/// Attach a B2B financial tag to an authorized `<nfeProc>` XML,
311/// wrapping both in a `<nfeProcB2B>` element.
312///
313/// # Arguments
314///
315/// * `nfe_proc_xml` - The authorized nfeProc XML.
316/// * `b2b_xml` - The B2B financial XML (must contain the `tag_b2b` element).
317/// * `tag_b2b` - Optional B2B tag name; defaults to `"NFeB2BFin"`.
318///
319/// # Errors
320///
321/// Returns [`FiscalError::XmlParsing`] if:
322/// - The `nfe_proc_xml` does not contain `<nfeProc>`
323/// - The `b2b_xml` does not contain the expected B2B tag
324/// - Either tag cannot be extracted
325pub fn attach_b2b(
326    nfe_proc_xml: &str,
327    b2b_xml: &str,
328    tag_b2b: Option<&str>,
329) -> Result<String, FiscalError> {
330    let tag_name = tag_b2b.unwrap_or("NFeB2BFin");
331
332    if !nfe_proc_xml.contains("<nfeProc") {
333        return Err(FiscalError::XmlParsing(
334            "XML does not contain <nfeProc> — is this an authorized NFe?".into(),
335        ));
336    }
337
338    let open_check = format!("<{tag_name}");
339    if !b2b_xml.contains(&open_check) {
340        return Err(FiscalError::XmlParsing(format!(
341            "B2B XML does not contain <{tag_name}> tag"
342        )));
343    }
344
345    let nfe_proc_content = extract_tag(nfe_proc_xml, "nfeProc")
346        .ok_or_else(|| FiscalError::XmlParsing("Could not extract <nfeProc> from XML".into()))?;
347
348    // PHP DOMDocument re-serializes <nfeProc> with xmlns before versao
349    // (DOM canonical attribute ordering). We must match this behavior.
350    let nfe_proc_normalized = normalize_nfe_proc_attrs(&nfe_proc_content);
351
352    let b2b_content = extract_tag(b2b_xml, tag_name).ok_or_else(|| {
353        FiscalError::XmlParsing(format!("Could not extract <{tag_name}> from B2B XML"))
354    })?;
355
356    let raw = format!(
357        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
358         <nfeProcB2B>{nfe_proc_normalized}{b2b_content}</nfeProcB2B>"
359    );
360
361    // PHP Complements::b2bTag line 79 does:
362    //   str_replace(array("\n", "\r", "\s"), '', $nfeb2bXML)
363    // This removes newlines/carriage-returns (and the literal "\s" which is
364    // a PHP quirk — "\s" inside single quotes is just the characters \ and s,
365    // but that string never appears in XML anyway).
366    let cleaned = strip_newlines(&raw);
367    Ok(cleaned)
368}
369
370/// Remove `\n` and `\r` characters from a string.
371///
372/// Mirrors the PHP `str_replace(array("\n", "\r", "\s"), '', ...)` call
373/// in `Complements::b2bTag`. The `\s` in PHP single-quoted strings is the
374/// literal two-character sequence `\s`, not a regex; we replicate by also
375/// removing it just in case, though it should never appear in valid XML.
376fn strip_newlines(s: &str) -> String {
377    let mut result = String::with_capacity(s.len());
378    let mut chars = s.chars().peekable();
379    while let Some(c) = chars.next() {
380        if c == '\n' || c == '\r' {
381            continue;
382        }
383        if c == '\\' {
384            if let Some(&'s') = chars.peek() {
385                chars.next(); // consume the 's'
386                continue;
387            }
388        }
389        result.push(c);
390    }
391    result
392}
393
394// ── Unified routing (mirrors PHP Complements::toAuthorize) ──────────────────
395
396/// Detect the document type from raw XML and dispatch to the correct
397/// protocol-attachment function.
398///
399/// This mirrors the PHP `Complements::toAuthorize()` method, which uses
400/// `Standardize::whichIs()` internally. The detection logic checks for
401/// the same root tags in the same priority order as the PHP implementation:
402///
403/// | Detected tag    | Dispatches to                  |
404/// |-----------------|-------------------------------|
405/// | `NFe`           | [`attach_protocol`]           |
406/// | `envEvento`     | [`attach_event_protocol`]     |
407/// | `inutNFe`       | [`attach_inutilizacao`]       |
408///
409/// # Errors
410///
411/// Returns [`FiscalError::XmlParsing`] if:
412/// - Either input is empty
413/// - The request XML does not match any of the known document types
414/// - The delegated function returns an error
415pub fn to_authorize(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
416    if request_xml.is_empty() {
417        return Err(FiscalError::XmlParsing(
418            "Erro ao protocolar: o XML a protocolar está vazio.".into(),
419        ));
420    }
421    if response_xml.is_empty() {
422        return Err(FiscalError::XmlParsing(
423            "Erro ao protocolar: o retorno da SEFAZ está vazio.".into(),
424        ));
425    }
426
427    // Detect using the same tag order as PHP Standardize::whichIs() + the
428    // ucfirst() / if-check in toAuthorize().
429    // PHP checks: whichIs() returns the root tag name from rootTagList,
430    // then toAuthorize() accepts only "NFe", "EnvEvento", "InutNFe".
431    // We search for these tags in the XML content:
432    if contains_xml_tag(request_xml, "NFe") {
433        attach_protocol(request_xml, response_xml)
434    } else if contains_xml_tag(request_xml, "envEvento") {
435        attach_event_protocol(request_xml, response_xml)
436    } else if contains_xml_tag(request_xml, "inutNFe") {
437        attach_inutilizacao(request_xml, response_xml)
438    } else {
439        Err(FiscalError::XmlParsing(
440            "Tipo de documento não reconhecido para protocolação".into(),
441        ))
442    }
443}
444
445/// Check if an XML string contains a given tag (with proper delimiter check).
446fn contains_xml_tag(xml: &str, tag_name: &str) -> bool {
447    let pattern = format!("<{tag_name}");
448    for (i, _) in xml.match_indices(&pattern) {
449        let after = i + pattern.len();
450        if after >= xml.len() {
451            return true;
452        }
453        let next = xml.as_bytes()[after];
454        if next == b' '
455            || next == b'>'
456            || next == b'/'
457            || next == b'\n'
458            || next == b'\r'
459            || next == b'\t'
460        {
461            return true;
462        }
463    }
464    false
465}
466
467/// Cancellation event type code (`110111`).
468const EVT_CANCELA: &str = "110111";
469/// Cancellation by substitution event type code (`110112`).
470const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
471
472/// Valid status codes for cancellation event matching.
473///
474/// - `135` — Event registered and linked
475/// - `136` — Event registered but not linked
476/// - `155` — Already cancelled (late)
477const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
478
479/// Attach a cancellation event response to an authorized `<nfeProc>` XML,
480/// marking the NF-e as locally cancelled.
481///
482/// This mirrors the PHP `Complements::cancelRegister()` method. The function
483/// searches the `cancel_event_xml` for `<retEvento>` elements whose:
484/// - `cStat` is in `[135, 136, 155]` (valid cancellation statuses)
485/// - `tpEvento` is `110111` (cancellation) or `110112` (cancellation by substitution)
486/// - `chNFe` matches the access key in the authorized NF-e's `<protNFe>`
487///
488/// When a matching `<retEvento>` is found, it is appended inside the
489/// `<nfeProc>` element (before the closing `</nfeProc>` tag).
490///
491/// If no matching cancellation event is found, the original NF-e XML is
492/// returned unchanged (same behavior as the PHP implementation).
493///
494/// # Arguments
495///
496/// * `nfe_proc_xml` - The authorized NF-e XML containing `<nfeProc>` with `<protNFe>`.
497/// * `cancel_event_xml` - The SEFAZ cancellation event response XML containing `<retEvento>`.
498///
499/// # Errors
500///
501/// Returns [`FiscalError::XmlParsing`] if:
502/// - The `nfe_proc_xml` does not contain `<protNFe>` (not an authorized NF-e)
503/// - The `<protNFe>` does not contain `<chNFe>`
504pub fn attach_cancellation(
505    nfe_proc_xml: &str,
506    cancel_event_xml: &str,
507) -> Result<String, FiscalError> {
508    // Validate the NF-e has a protNFe with a chNFe
509    let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
510        FiscalError::XmlParsing(
511            "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
512        )
513    })?;
514
515    let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
516        .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
517
518    // Search for matching retEvento in the cancellation XML
519    let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
520
521    let mut matched_ret_evento: Option<&str> = None;
522
523    for ret_evento in &ret_eventos {
524        let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
525            Some(v) => v,
526            None => continue,
527        };
528        let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
529            Some(v) => v,
530            None => continue,
531        };
532        let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
533            Some(v) => v,
534            None => continue,
535        };
536
537        if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
538            && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
539            && ch_nfe_evento == ch_nfe
540        {
541            matched_ret_evento = Some(ret_evento.as_str());
542            break;
543        }
544    }
545
546    // Re-serialize via DOM-like logic to match PHP DOMDocument::saveXML().
547    // PHP always re-serializes even when no match is found — reordering
548    // xmlns before versao and emitting an XML declaration + newline.
549    dom_reserialize_nfe_proc(nfe_proc_xml, matched_ret_evento)
550}
551
552// ── Internal helpers ────────────────────────────────────────────────────────
553
554/// Normalize the opening `<nfeProc>` tag so that `xmlns` comes before `versao`,
555/// matching PHP DOMDocument canonical attribute ordering.
556///
557/// If the tag already has `xmlns` before `versao`, or if either attribute is
558/// missing, the string is returned unchanged.
559fn normalize_nfe_proc_attrs(nfe_proc_xml: &str) -> String {
560    let xmlns = extract_attribute(nfe_proc_xml, "nfeProc", "xmlns");
561    let versao = extract_attribute(nfe_proc_xml, "nfeProc", "versao");
562
563    if let (Some(xmlns_val), Some(versao_val)) = (xmlns, versao) {
564        // Find the opening tag range
565        let open_pattern = "<nfeProc";
566        if let Some(start) = nfe_proc_xml.find(open_pattern) {
567            if let Some(gt_offset) = nfe_proc_xml[start..].find('>') {
568                let gt_pos = start + gt_offset;
569                let old_opening = &nfe_proc_xml[start..=gt_pos];
570                let new_opening =
571                    format!("<nfeProc xmlns=\"{xmlns_val}\" versao=\"{versao_val}\">");
572                if old_opening != new_opening {
573                    return nfe_proc_xml.replacen(old_opening, &new_opening, 1);
574                }
575            }
576        }
577    }
578
579    nfe_proc_xml.to_string()
580}
581
582/// Re-serialize an `<nfeProc>` XML in a way that matches PHP
583/// `DOMDocument::saveXML()` output.
584///
585/// PHP DOM re-serialization does the following:
586///
587/// 1. Emits `<?xml version="1.0" encoding="..."?>` followed by a newline (`\n`).
588///    The encoding is preserved from the original XML declaration; if absent,
589///    the declaration is emitted without an encoding attribute.
590/// 2. Reorders attributes on `<nfeProc>`: `xmlns` comes before `versao`
591///    (DOM canonical ordering puts namespace declarations first).
592/// 3. All other element content is preserved as-is.
593///
594/// If `extra_child` is `Some`, it is appended as the last child inside
595/// `<nfeProc>` (before `</nfeProc>`).
596fn dom_reserialize_nfe_proc(
597    nfe_proc_xml: &str,
598    extra_child: Option<&str>,
599) -> Result<String, FiscalError> {
600    // 1. Extract the encoding from the original XML declaration (if any).
601    let encoding = extract_xml_declaration_encoding(nfe_proc_xml);
602
603    // 2. Build the XML declaration exactly as PHP DOMDocument::saveXML() does.
604    let xml_decl = match &encoding {
605        Some(enc) => format!("<?xml version=\"1.0\" encoding=\"{enc}\"?>"),
606        None => "<?xml version=\"1.0\"?>".to_string(),
607    };
608
609    // 3. Extract the <nfeProc> tag attributes and body content.
610    //    We need to rewrite the opening tag with xmlns before versao.
611    let nfe_proc_full = extract_tag(nfe_proc_xml, "nfeProc")
612        .ok_or_else(|| FiscalError::XmlParsing("Could not find <nfeProc> in NF-e XML".into()))?;
613
614    // Extract versao and xmlns from the <nfeProc> opening tag
615    let versao = extract_attribute(&nfe_proc_full, "nfeProc", "versao")
616        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
617    let xmlns = extract_attribute(&nfe_proc_full, "nfeProc", "xmlns")
618        .unwrap_or_else(|| NFE_NAMESPACE.to_string());
619
620    // Extract the inner content of <nfeProc> (everything between the opening
621    // and closing tags).
622    let inner = extract_tag_inner_content(&nfe_proc_full, "nfeProc").ok_or_else(|| {
623        FiscalError::XmlParsing("Could not extract <nfeProc> inner content".into())
624    })?;
625
626    // 4. Reassemble with PHP attribute order: xmlns first, then versao.
627    let mut result = String::with_capacity(
628        xml_decl.len() + 1 + 60 + inner.len() + extra_child.map_or(0, |c| c.len()) + 12,
629    );
630    result.push_str(&xml_decl);
631    result.push('\n');
632    result.push_str(&format!("<nfeProc xmlns=\"{xmlns}\" versao=\"{versao}\">"));
633    result.push_str(inner);
634    if let Some(child) = extra_child {
635        result.push_str(child);
636    }
637    result.push_str("</nfeProc>\n");
638
639    Ok(result)
640}
641
642/// Extract the `encoding` value from an XML declaration, if present.
643///
644/// Given `<?xml version="1.0" encoding="UTF-8"?>`, returns `Some("UTF-8")`.
645/// Given `<?xml version="1.0"?>` or no declaration, returns `None`.
646fn extract_xml_declaration_encoding(xml: &str) -> Option<String> {
647    let decl_start = xml.find("<?xml ")?;
648    let decl_end = xml[decl_start..].find("?>")? + decl_start;
649    let decl = &xml[decl_start..decl_end + 2];
650
651    let enc_pat = "encoding=\"";
652    let enc_start = decl.find(enc_pat)? + enc_pat.len();
653    let enc_end = decl[enc_start..].find('"')? + enc_start;
654    Some(decl[enc_start..enc_end].to_string())
655}
656
657/// Extract the inner content of an XML tag (everything between the end of the
658/// opening tag `>` and the start of the closing tag `</tagName>`).
659fn extract_tag_inner_content<'a>(xml: &'a str, tag_name: &str) -> Option<&'a str> {
660    let open_pattern = format!("<{tag_name}");
661    let start = xml.find(&open_pattern)?;
662
663    // Find end of opening tag
664    let gt_pos = xml[start..].find('>')? + start;
665
666    let close_tag = format!("</{tag_name}>");
667    let close_pos = xml.rfind(&close_tag)?;
668
669    if gt_pos + 1 > close_pos {
670        return Some("");
671    }
672
673    Some(&xml[gt_pos + 1..close_pos])
674}
675
676/// Join two XML fragments into a versioned namespace wrapper element.
677///
678/// Produces:
679/// ```xml
680/// <?xml version="1.0" encoding="UTF-8"?>
681/// <{node_name} versao="{version}" xmlns="{NFE_NAMESPACE}">
682///   {first}{second}
683/// </{node_name}>
684/// ```
685fn join_xml(first: &str, second: &str, node_name: &str, version: &str) -> String {
686    format!(
687        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
688         <{node_name} versao=\"{version}\" xmlns=\"{NFE_NAMESPACE}\">\
689         {first}{second}</{node_name}>"
690    )
691}
692
693/// Extract a complete XML tag (outermost match) including attributes and
694/// all nested content. Uses `lastIndexOf`-style search for the closing tag
695/// to handle nested tags of the same name.
696///
697/// Returns `None` if either the opening or closing tag is not found.
698fn extract_tag(xml: &str, tag_name: &str) -> Option<String> {
699    // Find the opening tag: <tagName followed by whitespace, >, or /
700    let open_pattern = format!("<{tag_name}");
701    let start = xml.find(&open_pattern)?;
702
703    // Verify that the character after `<tagName` is a valid delimiter
704    // (space, >, /) to avoid matching tags like `<tagNameExtra>`
705    let after_open = start + open_pattern.len();
706    if after_open < xml.len() {
707        let next_char = xml.as_bytes()[after_open];
708        if next_char != b' '
709            && next_char != b'>'
710            && next_char != b'/'
711            && next_char != b'\n'
712            && next_char != b'\r'
713            && next_char != b'\t'
714        {
715            return None;
716        }
717    }
718
719    let close_tag = format!("</{tag_name}>");
720    let close_index = xml.rfind(&close_tag)?;
721
722    Some(xml[start..close_index + close_tag.len()].to_string())
723}
724
725/// Extract all occurrences of a tag from XML. Finds each non-overlapping
726/// `<tagName ...>...</tagName>` in the source string.
727fn extract_all_tags(xml: &str, tag_name: &str) -> Vec<String> {
728    let mut results = Vec::new();
729    let open_pattern = format!("<{tag_name}");
730    let close_tag = format!("</{tag_name}>");
731    let mut search_from = 0;
732
733    while search_from < xml.len() {
734        let start = match xml[search_from..].find(&open_pattern) {
735            Some(pos) => search_from + pos,
736            None => break,
737        };
738
739        // Verify delimiter after tag name
740        let after_open = start + open_pattern.len();
741        if after_open < xml.len() {
742            let next_char = xml.as_bytes()[after_open];
743            if next_char != b' '
744                && next_char != b'>'
745                && next_char != b'/'
746                && next_char != b'\n'
747                && next_char != b'\r'
748                && next_char != b'\t'
749            {
750                search_from = after_open;
751                continue;
752            }
753        }
754
755        let end = match xml[start..].find(&close_tag) {
756            Some(pos) => start + pos + close_tag.len(),
757            None => break,
758        };
759
760        results.push(xml[start..end].to_string());
761        search_from = end;
762    }
763
764    results
765}
766
767/// Extract an XML attribute value from a tag. Searches for the tag opening
768/// then finds `attr="value"` within it.
769fn extract_attribute(xml: &str, tag_name: &str, attr_name: &str) -> Option<String> {
770    let open = format!("<{tag_name}");
771    let start = xml.find(&open)?;
772
773    // Find the end of the opening tag
774    let tag_end = xml[start..].find('>')? + start;
775    let tag_header = &xml[start..tag_end];
776
777    // Find attr="value" pattern
778    let attr_pattern = format!("{attr_name}=\"");
779    let attr_start = tag_header.find(&attr_pattern)? + attr_pattern.len();
780    let attr_end = tag_header[attr_start..].find('"')? + attr_start;
781
782    Some(tag_header[attr_start..attr_end].to_string())
783}
784
785/// Extract the access key from an `<infNFe Id="NFe...">` attribute.
786/// Returns the 44-digit key (without the "NFe" prefix).
787fn extract_inf_nfe_id(xml: &str) -> Option<String> {
788    let attr_val = extract_attribute(xml, "infNFe", "Id")?;
789    Some(
790        attr_val
791            .strip_prefix("NFe")
792            .unwrap_or(&attr_val)
793            .to_string(),
794    )
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    #[test]
802    fn extract_tag_finds_outermost_match() {
803        let xml = r#"<root><NFe versao="4.00"><inner/></NFe></root>"#;
804        let result = extract_tag(xml, "NFe").unwrap();
805        assert!(result.starts_with("<NFe"));
806        assert!(result.ends_with("</NFe>"));
807        assert!(result.contains("<inner/>"));
808    }
809
810    #[test]
811    fn extract_tag_returns_none_for_missing_tag() {
812        let xml = "<root><other/></root>";
813        assert!(extract_tag(xml, "NFe").is_none());
814    }
815
816    #[test]
817    fn extract_tag_does_not_match_prefix() {
818        let xml = "<root><NFeExtra>data</NFeExtra></root>";
819        assert!(extract_tag(xml, "NFe").is_none());
820    }
821
822    #[test]
823    fn extract_attribute_works() {
824        let xml = r#"<infNFe versao="4.00" Id="NFe12345">"#;
825        assert_eq!(
826            extract_attribute(xml, "infNFe", "versao"),
827            Some("4.00".to_string())
828        );
829        assert_eq!(
830            extract_attribute(xml, "infNFe", "Id"),
831            Some("NFe12345".to_string())
832        );
833    }
834
835    #[test]
836    fn extract_all_tags_finds_multiple() {
837        let xml = r#"<root><item>1</item><item>2</item><item>3</item></root>"#;
838        let items = extract_all_tags(xml, "item");
839        assert_eq!(items.len(), 3);
840        assert!(items[0].contains("1"));
841        assert!(items[2].contains("3"));
842    }
843
844    #[test]
845    fn join_xml_produces_correct_wrapper() {
846        let result = join_xml("<A/>", "<B/>", "wrapper", "4.00");
847        assert!(result.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
848        assert!(result.contains("<wrapper versao=\"4.00\""));
849        assert!(result.contains(&format!("xmlns=\"{NFE_NAMESPACE}\"")));
850        assert!(result.ends_with("</wrapper>"));
851    }
852
853    #[test]
854    fn extract_inf_nfe_id_strips_prefix() {
855        let xml = r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780"></infNFe></NFe>"#;
856        let key = extract_inf_nfe_id(xml).unwrap();
857        assert_eq!(key, "35260112345678000199650010000000011123456780");
858    }
859
860    // ── attach_cancellation tests ─────────────────────────────────────
861
862    #[test]
863    fn attach_cancellation_appends_matching_ret_evento() {
864        let nfe_proc = concat!(
865            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
866            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
867            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
868            r#"<ide/></infNFe></NFe>"#,
869            r#"<protNFe versao="4.00"><infProt>"#,
870            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
871            r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
872            r#"</infProt></protNFe>"#,
873            r#"</nfeProc>"#
874        );
875
876        let cancel_xml = concat!(
877            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
878            r#"<cStat>135</cStat>"#,
879            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
880            r#"<tpEvento>110111</tpEvento>"#,
881            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
882            r#"<nProt>135220000009999</nProt>"#,
883            r#"</infEvento></retEvento></retEnvEvento>"#
884        );
885
886        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
887
888        // Must contain the retEvento inside nfeProc
889        assert!(
890            result.contains("<retEvento"),
891            "Result should contain <retEvento>"
892        );
893        assert!(
894            result.contains("<tpEvento>110111</tpEvento>"),
895            "Result should contain cancellation event type"
896        );
897        // The retEvento should appear before </nfeProc>
898        let ret_pos = result.find("<retEvento").unwrap();
899        let close_pos = result.rfind("</nfeProc>").unwrap();
900        assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
901        // Original content should be preserved
902        assert!(result.contains("<protNFe"));
903        assert!(result.contains("<NFe>"));
904    }
905
906    #[test]
907    fn attach_cancellation_ignores_non_matching_ch_nfe() {
908        let nfe_proc = concat!(
909            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
910            r#"<NFe/>"#,
911            r#"<protNFe versao="4.00"><infProt>"#,
912            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
913            r#"<cStat>100</cStat>"#,
914            r#"</infProt></protNFe>"#,
915            r#"</nfeProc>"#
916        );
917
918        let cancel_xml = concat!(
919            r#"<retEvento versao="1.00"><infEvento>"#,
920            r#"<cStat>135</cStat>"#,
921            r#"<tpEvento>110111</tpEvento>"#,
922            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
923            r#"<nProt>135220000009999</nProt>"#,
924            r#"</infEvento></retEvento>"#
925        );
926
927        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
928        // No matching chNFe — should NOT contain retEvento, but still
929        // re-serialized like PHP (declaration + xmlns before versao)
930        assert!(
931            !result.contains("<retEvento"),
932            "Should not contain retEvento"
933        );
934        assert!(
935            result.starts_with("<?xml version=\"1.0\"?>\n"),
936            "Should have XML declaration (no encoding since input had none)"
937        );
938        assert!(
939            result
940                .contains(r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#),
941            "Should reorder xmlns before versao"
942        );
943    }
944
945    #[test]
946    fn attach_cancellation_ignores_wrong_tp_evento() {
947        let nfe_proc = concat!(
948            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
949            r#"<NFe/>"#,
950            r#"<protNFe versao="4.00"><infProt>"#,
951            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
952            r#"<cStat>100</cStat>"#,
953            r#"</infProt></protNFe>"#,
954            r#"</nfeProc>"#
955        );
956
957        let cancel_xml = concat!(
958            r#"<retEvento versao="1.00"><infEvento>"#,
959            r#"<cStat>135</cStat>"#,
960            r#"<tpEvento>110110</tpEvento>"#, // CCe, not cancellation
961            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
962            r#"<nProt>135220000009999</nProt>"#,
963            r#"</infEvento></retEvento>"#
964        );
965
966        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
967        // Wrong tpEvento — should NOT contain retEvento
968        assert!(
969            !result.contains("<retEvento"),
970            "Should not contain retEvento"
971        );
972        assert!(
973            result.starts_with("<?xml version=\"1.0\"?>\n"),
974            "Should have XML declaration"
975        );
976    }
977
978    #[test]
979    fn attach_cancellation_ignores_rejected_status() {
980        let nfe_proc = concat!(
981            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
982            r#"<NFe/>"#,
983            r#"<protNFe versao="4.00"><infProt>"#,
984            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
985            r#"<cStat>100</cStat>"#,
986            r#"</infProt></protNFe>"#,
987            r#"</nfeProc>"#
988        );
989
990        let cancel_xml = concat!(
991            r#"<retEvento versao="1.00"><infEvento>"#,
992            r#"<cStat>573</cStat>"#, // Rejected status
993            r#"<tpEvento>110111</tpEvento>"#,
994            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
995            r#"<nProt>135220000009999</nProt>"#,
996            r#"</infEvento></retEvento>"#
997        );
998
999        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1000        // Rejected status — should NOT contain retEvento
1001        assert!(
1002            !result.contains("<retEvento"),
1003            "Should not contain retEvento"
1004        );
1005        assert!(
1006            result.starts_with("<?xml version=\"1.0\"?>\n"),
1007            "Should have XML declaration"
1008        );
1009    }
1010
1011    #[test]
1012    fn attach_cancellation_accepts_status_155() {
1013        let nfe_proc = concat!(
1014            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1015            r#"<NFe/>"#,
1016            r#"<protNFe versao="4.00"><infProt>"#,
1017            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1018            r#"<cStat>100</cStat>"#,
1019            r#"</infProt></protNFe>"#,
1020            r#"</nfeProc>"#
1021        );
1022
1023        let cancel_xml = concat!(
1024            r#"<retEvento versao="1.00"><infEvento>"#,
1025            r#"<cStat>155</cStat>"#,
1026            r#"<tpEvento>110111</tpEvento>"#,
1027            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1028            r#"<nProt>135220000009999</nProt>"#,
1029            r#"</infEvento></retEvento>"#
1030        );
1031
1032        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1033        assert!(result.contains("<retEvento"));
1034    }
1035
1036    #[test]
1037    fn attach_cancellation_accepts_substituicao_110112() {
1038        let nfe_proc = concat!(
1039            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1040            r#"<NFe/>"#,
1041            r#"<protNFe versao="4.00"><infProt>"#,
1042            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1043            r#"<cStat>100</cStat>"#,
1044            r#"</infProt></protNFe>"#,
1045            r#"</nfeProc>"#
1046        );
1047
1048        let cancel_xml = concat!(
1049            r#"<retEvento versao="1.00"><infEvento>"#,
1050            r#"<cStat>135</cStat>"#,
1051            r#"<tpEvento>110112</tpEvento>"#,
1052            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1053            r#"<nProt>135220000009999</nProt>"#,
1054            r#"</infEvento></retEvento>"#
1055        );
1056
1057        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1058        assert!(
1059            result.contains("<tpEvento>110112</tpEvento>"),
1060            "Should accept cancellation by substitution"
1061        );
1062    }
1063
1064    #[test]
1065    fn attach_cancellation_rejects_missing_prot_nfe() {
1066        let nfe_xml = "<NFe><infNFe/></NFe>";
1067        let cancel_xml = "<retEvento/>";
1068        let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
1069        assert!(matches!(err, FiscalError::XmlParsing(_)));
1070    }
1071
1072    #[test]
1073    fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
1074        let nfe_proc = concat!(
1075            r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
1076            r#"<cStat>100</cStat>"#,
1077            r#"</infProt></protNFe></nfeProc>"#
1078        );
1079        let cancel_xml = "<retEvento/>";
1080        let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
1081        assert!(matches!(err, FiscalError::XmlParsing(_)));
1082    }
1083
1084    /// Byte-for-byte parity test: Rust output must match what PHP
1085    /// `Complements::cancelRegister()` produces for the same inputs.
1086    ///
1087    /// PHP uses `DOMDocument::saveXML()` which:
1088    /// 1. Emits `<?xml version="1.0" encoding="UTF-8"?>` + `\n`
1089    /// 2. Reorders `xmlns` before `versao` on `<nfeProc>`
1090    /// 3. Appends `<retEvento>` as last child of `<nfeProc>`
1091    #[test]
1092    fn attach_cancellation_parity_with_php() {
1093        // Input: authorized nfeProc (as produced by join_xml / PHP join())
1094        let nfe_proc = concat!(
1095            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1096            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1097            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1098            r#"<ide/></infNFe></NFe>"#,
1099            r#"<protNFe versao="4.00"><infProt>"#,
1100            r#"<digVal>abc</digVal>"#,
1101            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1102            r#"<cStat>100</cStat>"#,
1103            r#"<xMotivo>Autorizado</xMotivo>"#,
1104            r#"<nProt>135220000009921</nProt>"#,
1105            r#"</infProt></protNFe>"#,
1106            r#"</nfeProc>"#
1107        );
1108
1109        let cancel_xml = concat!(
1110            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1111            r#"<cStat>135</cStat>"#,
1112            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1113            r#"<tpEvento>110111</tpEvento>"#,
1114            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1115            r#"<nProt>135220000009999</nProt>"#,
1116            r#"</infEvento></retEvento></retEnvEvento>"#
1117        );
1118
1119        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1120
1121        // Expected output from PHP DOMDocument::saveXML():
1122        // - declaration with encoding="UTF-8" followed by \n
1123        // - <nfeProc xmlns="..." versao="..."> (xmlns first)
1124        // - inner content unchanged
1125        // - <retEvento> appended before </nfeProc>
1126        let expected = concat!(
1127            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1128            r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
1129            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1130            r#"<ide/></infNFe></NFe>"#,
1131            r#"<protNFe versao="4.00"><infProt>"#,
1132            r#"<digVal>abc</digVal>"#,
1133            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1134            r#"<cStat>100</cStat>"#,
1135            r#"<xMotivo>Autorizado</xMotivo>"#,
1136            r#"<nProt>135220000009921</nProt>"#,
1137            r#"</infProt></protNFe>"#,
1138            r#"<retEvento versao="1.00"><infEvento>"#,
1139            r#"<cStat>135</cStat>"#,
1140            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
1141            r#"<tpEvento>110111</tpEvento>"#,
1142            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1143            r#"<nProt>135220000009999</nProt>"#,
1144            r#"</infEvento></retEvento>"#,
1145            "</nfeProc>\n"
1146        );
1147
1148        assert_eq!(result, expected);
1149    }
1150
1151    /// Parity test for no-match case: PHP still re-serializes through saveXML().
1152    #[test]
1153    fn attach_cancellation_no_match_still_reserializes_like_php() {
1154        let nfe_proc = concat!(
1155            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1156            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1157            r#"<NFe/>"#,
1158            r#"<protNFe versao="4.00"><infProt>"#,
1159            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1160            r#"<cStat>100</cStat>"#,
1161            r#"</infProt></protNFe>"#,
1162            r#"</nfeProc>"#
1163        );
1164
1165        let cancel_xml = concat!(
1166            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
1167            r#"<cStat>135</cStat>"#,
1168            r#"<tpEvento>110111</tpEvento>"#,
1169            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1170            r#"<nProt>135220000009999</nProt>"#,
1171            r#"</infEvento></retEvento></retEnvEvento>"#
1172        );
1173
1174        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1175
1176        // PHP DOMDocument::saveXML() output (no retEvento appended):
1177        let expected = concat!(
1178            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
1179            r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
1180            r#"<NFe/>"#,
1181            r#"<protNFe versao="4.00"><infProt>"#,
1182            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1183            r#"<cStat>100</cStat>"#,
1184            r#"</infProt></protNFe>"#,
1185            "</nfeProc>\n"
1186        );
1187
1188        assert_eq!(result, expected);
1189    }
1190
1191    /// Test helpers for the DOM re-serialization.
1192    #[test]
1193    fn extract_xml_declaration_encoding_works() {
1194        assert_eq!(
1195            extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#),
1196            Some("UTF-8".to_string())
1197        );
1198        assert_eq!(
1199            extract_xml_declaration_encoding(r#"<?xml version="1.0" encoding="utf-8"?><root/>"#),
1200            Some("utf-8".to_string())
1201        );
1202        assert_eq!(
1203            extract_xml_declaration_encoding(r#"<?xml version="1.0"?><root/>"#),
1204            None
1205        );
1206        assert_eq!(extract_xml_declaration_encoding(r#"<root/>"#), None);
1207    }
1208
1209    #[test]
1210    fn extract_tag_inner_content_works() {
1211        assert_eq!(
1212            extract_tag_inner_content(r#"<root attr="val">inner content</root>"#, "root"),
1213            Some("inner content")
1214        );
1215        assert_eq!(
1216            extract_tag_inner_content(r#"<root></root>"#, "root"),
1217            Some("")
1218        );
1219    }
1220
1221    // ── attach_protocol tests ─────────────────────────────────────
1222
1223    #[test]
1224    fn attach_protocol_empty_request_xml() {
1225        let err = attach_protocol("", "<protNFe/>").unwrap_err();
1226        assert!(matches!(err, FiscalError::XmlParsing(_)));
1227    }
1228
1229    #[test]
1230    fn attach_protocol_empty_response_xml() {
1231        let err = attach_protocol("<NFe/>", "").unwrap_err();
1232        assert!(matches!(err, FiscalError::XmlParsing(_)));
1233    }
1234
1235    #[test]
1236    fn attach_protocol_matching_digest_and_key() {
1237        let request = concat!(
1238            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1239            r#"<ide/></infNFe>"#,
1240            r#"<Signature><SignedInfo/><SignatureValue/>"#,
1241            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1242            r#"</NFe>"#
1243        );
1244        let response = concat!(
1245            r#"<protNFe versao="4.00"><infProt>"#,
1246            r#"<digVal>abc123</digVal>"#,
1247            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1248            r#"<cStat>100</cStat>"#,
1249            r#"<xMotivo>Autorizado</xMotivo>"#,
1250            r#"</infProt></protNFe>"#
1251        );
1252        let result = attach_protocol(request, response).unwrap();
1253        assert!(result.contains("<nfeProc"));
1254        assert!(result.contains("</nfeProc>"));
1255        assert!(result.contains("<NFe>"));
1256        assert!(result.contains("<protNFe"));
1257    }
1258
1259    #[test]
1260    fn attach_protocol_rejected_status_in_exact_match() {
1261        let request = concat!(
1262            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1263            r#"<ide/></infNFe>"#,
1264            r#"<Signature><SignedInfo/><SignatureValue/>"#,
1265            r#"<KeyInfo><DigestValue>abc123</DigestValue></KeyInfo></Signature>"#,
1266            r#"</NFe>"#
1267        );
1268        let response = concat!(
1269            r#"<protNFe versao="4.00"><infProt>"#,
1270            r#"<digVal>abc123</digVal>"#,
1271            r#"<chNFe>35260112345678000199650010000000011123456780</chNFe>"#,
1272            r#"<cStat>999</cStat>"#,
1273            r#"<xMotivo>Rejeitada</xMotivo>"#,
1274            r#"</infProt></protNFe>"#
1275        );
1276        let err = attach_protocol(request, response).unwrap_err();
1277        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1278    }
1279
1280    #[test]
1281    fn attach_protocol_fallback_rejected_status() {
1282        // No digest match, falls back to first protNFe which is rejected
1283        let request = concat!(
1284            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199650010000000011123456780">"#,
1285            r#"<ide/></infNFe></NFe>"#
1286        );
1287        let response = concat!(
1288            r#"<protNFe versao="4.00"><infProt>"#,
1289            r#"<cStat>999</cStat>"#,
1290            r#"<xMotivo>Rejeitada</xMotivo>"#,
1291            r#"</infProt></protNFe>"#
1292        );
1293        let err = attach_protocol(request, response).unwrap_err();
1294        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1295    }
1296
1297    // ── attach_inutilizacao tests ───────────────────────────────────
1298
1299    #[test]
1300    fn attach_inutilizacao_empty_request() {
1301        let err = attach_inutilizacao("", "<retInutNFe/>").unwrap_err();
1302        assert!(matches!(err, FiscalError::XmlParsing(_)));
1303    }
1304
1305    #[test]
1306    fn attach_inutilizacao_empty_response() {
1307        let err = attach_inutilizacao("<inutNFe/>", "").unwrap_err();
1308        assert!(matches!(err, FiscalError::XmlParsing(_)));
1309    }
1310
1311    #[test]
1312    fn attach_inutilizacao_missing_inut_tag() {
1313        let err = attach_inutilizacao("<other/>", "<retInutNFe><cStat>102</cStat></retInutNFe>")
1314            .unwrap_err();
1315        assert!(matches!(err, FiscalError::XmlParsing(_)));
1316    }
1317
1318    #[test]
1319    fn attach_inutilizacao_missing_ret_tag() {
1320        let err = attach_inutilizacao(r#"<inutNFe versao="4.00"><data/></inutNFe>"#, "<other/>")
1321            .unwrap_err();
1322        assert!(matches!(err, FiscalError::XmlParsing(_)));
1323    }
1324
1325    #[test]
1326    fn attach_inutilizacao_rejected_status() {
1327        let err = attach_inutilizacao(
1328            r#"<inutNFe versao="4.00"><data/></inutNFe>"#,
1329            r#"<retInutNFe><cStat>999</cStat><xMotivo>Erro</xMotivo></retInutNFe>"#,
1330        )
1331        .unwrap_err();
1332        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1333    }
1334
1335    #[test]
1336    fn attach_inutilizacao_success() {
1337        let result = attach_inutilizacao(
1338            r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#,
1339            r#"<retInutNFe><cStat>102</cStat><xMotivo>Inutilizacao de numero homologado</xMotivo></retInutNFe>"#,
1340        )
1341        .unwrap();
1342        assert!(result.contains("<ProcInutNFe"));
1343        assert!(result.contains("<inutNFe"));
1344        assert!(result.contains("<retInutNFe>"));
1345    }
1346
1347    // ── attach_event_protocol tests ─────────────────────────────────
1348
1349    #[test]
1350    fn attach_event_protocol_empty_request() {
1351        let err = attach_event_protocol("", "<retEvento/>").unwrap_err();
1352        assert!(matches!(err, FiscalError::XmlParsing(_)));
1353    }
1354
1355    #[test]
1356    fn attach_event_protocol_empty_response() {
1357        let err = attach_event_protocol("<evento/>", "").unwrap_err();
1358        assert!(matches!(err, FiscalError::XmlParsing(_)));
1359    }
1360
1361    #[test]
1362    fn attach_event_protocol_missing_evento() {
1363        let err = attach_event_protocol(
1364            "<other/>",
1365            "<retEvento><infEvento><cStat>135</cStat></infEvento></retEvento>",
1366        )
1367        .unwrap_err();
1368        assert!(matches!(err, FiscalError::XmlParsing(_)));
1369    }
1370
1371    #[test]
1372    fn attach_event_protocol_missing_ret_evento() {
1373        let err =
1374            attach_event_protocol(r#"<evento versao="1.00"><infEvento/></evento>"#, "<other/>")
1375                .unwrap_err();
1376        assert!(matches!(err, FiscalError::XmlParsing(_)));
1377    }
1378
1379    #[test]
1380    fn attach_event_protocol_rejected_status() {
1381        let err = attach_event_protocol(
1382            r#"<evento versao="1.00"><infEvento/></evento>"#,
1383            r#"<retEvento><infEvento><cStat>999</cStat><xMotivo>Rejeitado</xMotivo></infEvento></retEvento>"#,
1384        )
1385        .unwrap_err();
1386        assert!(matches!(err, FiscalError::SefazRejection { .. }));
1387    }
1388
1389    #[test]
1390    fn attach_event_protocol_success() {
1391        let request = concat!(
1392            r#"<envEvento><idLote>100</idLote>"#,
1393            r#"<evento versao="1.00"><infEvento Id="ID1234"/></evento>"#,
1394            r#"</envEvento>"#
1395        );
1396        let response = concat!(
1397            r#"<retEnvEvento><idLote>100</idLote>"#,
1398            r#"<retEvento><infEvento><cStat>135</cStat>"#,
1399            r#"<xMotivo>Evento registrado</xMotivo>"#,
1400            r#"</infEvento></retEvento></retEnvEvento>"#
1401        );
1402        let result = attach_event_protocol(request, response).unwrap();
1403        assert!(result.contains("<procEventoNFe"));
1404        assert!(result.contains("<evento"));
1405        assert!(result.contains("<retEvento>"));
1406    }
1407
1408    // ── attach_b2b tests ────────────────────────────────────────────
1409
1410    #[test]
1411    fn attach_b2b_no_nfe_proc() {
1412        let err = attach_b2b("<NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1413        assert!(matches!(err, FiscalError::XmlParsing(_)));
1414    }
1415
1416    #[test]
1417    fn attach_b2b_no_b2b_tag() {
1418        let err = attach_b2b("<nfeProc><NFe/></nfeProc>", "<other>data</other>", None).unwrap_err();
1419        assert!(matches!(err, FiscalError::XmlParsing(_)));
1420    }
1421
1422    #[test]
1423    fn attach_b2b_extract_failure() {
1424        // nfeProc without closing tag won't extract
1425        let err = attach_b2b("<nfeProc><NFe/>", "<NFeB2BFin>data</NFeB2BFin>", None).unwrap_err();
1426        assert!(matches!(err, FiscalError::XmlParsing(_)));
1427    }
1428
1429    #[test]
1430    fn attach_b2b_success() {
1431        let result = attach_b2b(
1432            "<nfeProc><NFe/><protNFe/></nfeProc>",
1433            "<NFeB2BFin><tag>data</tag></NFeB2BFin>",
1434            None,
1435        )
1436        .unwrap();
1437        assert!(result.contains("<nfeProcB2B>"));
1438        assert!(result.contains("<nfeProc>"));
1439        assert!(result.contains("<NFeB2BFin>"));
1440    }
1441
1442    #[test]
1443    fn attach_b2b_custom_tag() {
1444        let result = attach_b2b(
1445            "<nfeProc><NFe/><protNFe/></nfeProc>",
1446            "<CustomB2B><tag>data</tag></CustomB2B>",
1447            Some("CustomB2B"),
1448        )
1449        .unwrap();
1450        assert!(result.contains("<CustomB2B>"));
1451    }
1452
1453    // ── extract_all_tags delimiter check ─────────────────────────────
1454
1455    #[test]
1456    fn extract_all_tags_skips_prefix_match() {
1457        // "protNFeExtra" should NOT be matched when looking for "protNFe"
1458        let xml = "<root><protNFeExtra>bad</protNFeExtra><protNFe>good</protNFe></root>";
1459        let results = extract_all_tags(xml, "protNFe");
1460        assert_eq!(results.len(), 1);
1461        assert!(results[0].contains("good"));
1462    }
1463
1464    #[test]
1465    fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
1466        let nfe_proc = concat!(
1467            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1468            r#"<NFe/>"#,
1469            r#"<protNFe versao="4.00"><infProt>"#,
1470            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1471            r#"<cStat>100</cStat>"#,
1472            r#"</infProt></protNFe>"#,
1473            r#"</nfeProc>"#
1474        );
1475
1476        let cancel_xml = concat!(
1477            r#"<retEnvEvento>"#,
1478            // First: wrong chNFe
1479            r#"<retEvento versao="1.00"><infEvento>"#,
1480            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1481            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
1482            r#"<nProt>111111111111111</nProt>"#,
1483            r#"</infEvento></retEvento>"#,
1484            // Second: correct match
1485            r#"<retEvento versao="1.00"><infEvento>"#,
1486            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
1487            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1488            r#"<nProt>222222222222222</nProt>"#,
1489            r#"</infEvento></retEvento>"#,
1490            r#"</retEnvEvento>"#
1491        );
1492
1493        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
1494        assert!(result.contains("<nProt>222222222222222</nProt>"));
1495        // Should only have one retEvento (the matching one)
1496        assert_eq!(result.matches("<retEvento").count(), 1);
1497    }
1498
1499    // ── to_authorize routing tests ──────────────────────────────────────
1500
1501    #[test]
1502    fn to_authorize_empty_request_returns_error() {
1503        let err = to_authorize("", "<retEnviNFe/>").unwrap_err();
1504        assert!(matches!(err, FiscalError::XmlParsing(_)));
1505    }
1506
1507    #[test]
1508    fn to_authorize_empty_response_returns_error() {
1509        let err = to_authorize("<NFe/>", "").unwrap_err();
1510        assert!(matches!(err, FiscalError::XmlParsing(_)));
1511    }
1512
1513    #[test]
1514    fn to_authorize_unrecognized_document_returns_error() {
1515        let err = to_authorize("<other>data</other>", "<response/>").unwrap_err();
1516        let msg = format!("{err}");
1517        assert!(
1518            msg.contains("não reconhecido"),
1519            "should mention unrecognized type: {msg}"
1520        );
1521    }
1522
1523    #[test]
1524    fn contains_xml_tag_basic() {
1525        assert!(contains_xml_tag("<NFe versao=\"4.00\">", "NFe"));
1526        assert!(contains_xml_tag("<NFe>", "NFe"));
1527        assert!(contains_xml_tag("<NFe/>", "NFe"));
1528        assert!(!contains_xml_tag("<NFeExtra>", "NFe"));
1529        assert!(contains_xml_tag("<envEvento versao=\"1.00\">", "envEvento"));
1530        assert!(contains_xml_tag("<inutNFe versao=\"4.00\">", "inutNFe"));
1531    }
1532
1533    // ── attach_b2b whitespace stripping tests ───────────────────────────
1534
1535    #[test]
1536    fn attach_b2b_strips_newlines() {
1537        let nfe_proc = "<nfeProc versao=\"4.00\">\n<NFe/>\n<protNFe/>\n</nfeProc>";
1538        let b2b = "<NFeB2BFin>\n<data>test</data>\n</NFeB2BFin>";
1539        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1540        assert!(!result.contains('\n'), "Result should not contain newlines");
1541        assert!(
1542            !result.contains('\r'),
1543            "Result should not contain carriage returns"
1544        );
1545        assert!(result.contains("<nfeProcB2B>"));
1546        assert!(result.contains("<NFeB2BFin>"));
1547    }
1548
1549    #[test]
1550    fn attach_b2b_strips_carriage_returns() {
1551        let nfe_proc = "<nfeProc versao=\"4.00\">\r\n<NFe/>\r\n</nfeProc>";
1552        let b2b = "<NFeB2BFin><data>test</data></NFeB2BFin>";
1553        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1554        assert!(!result.contains('\r'));
1555        assert!(!result.contains('\n'));
1556    }
1557
1558    // ── attach_protocol: fallback protNFe with invalid cStat (lines 112-116) ──
1559
1560    #[test]
1561    fn attach_protocol_fallback_prot_invalid_status() {
1562        // Request with NFe, digest, access key
1563        let request = concat!(
1564            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1565            r#"<DigestValue>abc123</DigestValue>"#,
1566            r#"</infNFe></NFe>"#
1567        );
1568        // Response with single protNFe that has NO digVal (trigger fallback),
1569        // but status is invalid
1570        let response = concat!(
1571            r#"<protNFe versao="4.00"><infProt>"#,
1572            r#"<cStat>999</cStat>"#,
1573            r#"<xMotivo>Rejeitado</xMotivo>"#,
1574            r#"</infProt></protNFe>"#
1575        );
1576        let err = attach_protocol(request, response).unwrap_err();
1577        match err {
1578            FiscalError::SefazRejection { code, .. } => assert_eq!(code, "999"),
1579            other => panic!("Expected SefazRejection, got {:?}", other),
1580        }
1581    }
1582
1583    // ── attach_inutilizacao: version mismatch (line 197) ────────────────
1584
1585    #[test]
1586    fn attach_inutilizacao_version_mismatch() {
1587        let request = concat!(
1588            r#"<inutNFe versao="4.00"><infInut>"#,
1589            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1590            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1591            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1592            r#"</infInut></inutNFe>"#
1593        );
1594        let response = concat!(
1595            r#"<retInutNFe versao="3.10"><infInut>"#,
1596            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1597            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1598            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1599            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1600            r#"</infInut></retInutNFe>"#
1601        );
1602        let err = attach_inutilizacao(request, response).unwrap_err();
1603        match err {
1604            FiscalError::XmlParsing(msg) => {
1605                assert!(
1606                    msg.contains("versao"),
1607                    "Expected version mismatch error, got: {msg}"
1608                );
1609            }
1610            other => panic!("Expected XmlParsing, got {:?}", other),
1611        }
1612    }
1613
1614    // ── attach_inutilizacao: tag mismatch (line 217) ────────────────────
1615
1616    #[test]
1617    fn attach_inutilizacao_tag_value_mismatch() {
1618        let request = concat!(
1619            r#"<inutNFe versao="4.00"><infInut>"#,
1620            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1621            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1622            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1623            r#"</infInut></inutNFe>"#
1624        );
1625        let response = concat!(
1626            r#"<retInutNFe versao="4.00"><infInut>"#,
1627            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1628            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1629            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>2</serie>"#,
1630            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1631            r#"</infInut></retInutNFe>"#
1632        );
1633        let err = attach_inutilizacao(request, response).unwrap_err();
1634        match err {
1635            FiscalError::XmlParsing(msg) => {
1636                assert!(
1637                    msg.contains("serie"),
1638                    "Expected serie mismatch error, got: {msg}"
1639                );
1640            }
1641            other => panic!("Expected XmlParsing, got {:?}", other),
1642        }
1643    }
1644
1645    // ── attach_event_protocol: idLote mismatch (lines 277-278) ──────────
1646
1647    #[test]
1648    fn attach_event_protocol_id_lote_mismatch() {
1649        let request = concat!(
1650            r#"<envEvento><idLote>100</idLote>"#,
1651            r#"<evento versao="1.00"><infEvento>"#,
1652            r#"<tpEvento>110110</tpEvento>"#,
1653            r#"</infEvento></evento></envEvento>"#
1654        );
1655        let response = concat!(
1656            r#"<retEnvEvento><idLote>999</idLote>"#,
1657            r#"<retEvento versao="1.00"><infEvento>"#,
1658            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1659            r#"<tpEvento>110110</tpEvento>"#,
1660            r#"</infEvento></retEvento></retEnvEvento>"#
1661        );
1662        let err = attach_event_protocol(request, response).unwrap_err();
1663        match err {
1664            FiscalError::XmlParsing(msg) => {
1665                assert!(
1666                    msg.contains("lote"),
1667                    "Expected lote mismatch error, got: {msg}"
1668                );
1669            }
1670            other => panic!("Expected XmlParsing, got {:?}", other),
1671        }
1672    }
1673
1674    // ── attach_event_protocol: cStat validated before idLote (PHP parity) ──
1675
1676    #[test]
1677    fn attach_event_protocol_both_invalid_reports_cstat_first() {
1678        // When BOTH cStat is invalid AND idLote mismatches, the error
1679        // must be about cStat (SefazRejection), not about idLote,
1680        // matching PHP addEnvEventoProtocol validation order.
1681        let request = concat!(
1682            r#"<envEvento><idLote>100</idLote>"#,
1683            r#"<evento versao="1.00"><infEvento>"#,
1684            r#"<tpEvento>110110</tpEvento>"#,
1685            r#"</infEvento></evento></envEvento>"#
1686        );
1687        let response = concat!(
1688            r#"<retEnvEvento><idLote>999</idLote>"#,
1689            r#"<retEvento versao="1.00"><infEvento>"#,
1690            r#"<cStat>573</cStat><xMotivo>Duplicidade de evento</xMotivo>"#,
1691            r#"<tpEvento>110110</tpEvento>"#,
1692            r#"</infEvento></retEvento></retEnvEvento>"#
1693        );
1694        let err = attach_event_protocol(request, response).unwrap_err();
1695        match err {
1696            FiscalError::SefazRejection { code, message } => {
1697                assert_eq!(code, "573");
1698                assert_eq!(message, "Duplicidade de evento");
1699            }
1700            other => panic!("Expected SefazRejection (cStat first), got {:?}", other),
1701        }
1702    }
1703
1704    // ── attach_event_protocol: missing idLote ─────────────────────────
1705
1706    #[test]
1707    fn attach_event_protocol_missing_id_lote_in_request() {
1708        let request = concat!(
1709            r#"<envEvento>"#,
1710            r#"<evento versao="1.00"><infEvento>"#,
1711            r#"<tpEvento>110110</tpEvento>"#,
1712            r#"</infEvento></evento></envEvento>"#
1713        );
1714        let response = concat!(
1715            r#"<retEnvEvento><idLote>100</idLote>"#,
1716            r#"<retEvento versao="1.00"><infEvento>"#,
1717            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1718            r#"<tpEvento>110110</tpEvento>"#,
1719            r#"</infEvento></retEvento></retEnvEvento>"#
1720        );
1721        let err = attach_event_protocol(request, response).unwrap_err();
1722        match err {
1723            FiscalError::XmlParsing(msg) => {
1724                assert_eq!(msg, "idLote not found in request XML");
1725            }
1726            other => panic!("Expected XmlParsing, got {:?}", other),
1727        }
1728    }
1729
1730    #[test]
1731    fn attach_event_protocol_missing_id_lote_in_response() {
1732        let request = concat!(
1733            r#"<envEvento><idLote>100</idLote>"#,
1734            r#"<evento versao="1.00"><infEvento>"#,
1735            r#"<tpEvento>110110</tpEvento>"#,
1736            r#"</infEvento></evento></envEvento>"#
1737        );
1738        let response = concat!(
1739            r#"<retEnvEvento>"#,
1740            r#"<retEvento versao="1.00"><infEvento>"#,
1741            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1742            r#"<tpEvento>110110</tpEvento>"#,
1743            r#"</infEvento></retEvento></retEnvEvento>"#
1744        );
1745        let err = attach_event_protocol(request, response).unwrap_err();
1746        match err {
1747            FiscalError::XmlParsing(msg) => {
1748                assert_eq!(msg, "idLote not found in response XML");
1749            }
1750            other => panic!("Expected XmlParsing, got {:?}", other),
1751        }
1752    }
1753
1754    #[test]
1755    fn attach_event_protocol_missing_id_lote_in_both() {
1756        let request = concat!(
1757            r#"<envEvento>"#,
1758            r#"<evento versao="1.00"><infEvento>"#,
1759            r#"<tpEvento>110110</tpEvento>"#,
1760            r#"</infEvento></evento></envEvento>"#
1761        );
1762        let response = concat!(
1763            r#"<retEnvEvento>"#,
1764            r#"<retEvento versao="1.00"><infEvento>"#,
1765            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1766            r#"<tpEvento>110110</tpEvento>"#,
1767            r#"</infEvento></retEvento></retEnvEvento>"#
1768        );
1769        let err = attach_event_protocol(request, response).unwrap_err();
1770        match err {
1771            FiscalError::XmlParsing(msg) => {
1772                assert_eq!(msg, "idLote not found in request XML");
1773            }
1774            other => panic!("Expected XmlParsing, got {:?}", other),
1775        }
1776    }
1777
1778    // ── attach_b2b: extract_tag for b2b content (line 348) ──────────────
1779
1780    #[test]
1781    fn attach_b2b_extract_tag_coverage() {
1782        let nfe_proc = concat!(
1783            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
1784            r#"<NFe><infNFe/></NFe><protNFe><infProt/></protNFe>"#,
1785            r#"</nfeProc>"#
1786        );
1787        let b2b = r#"<NFeB2BFin versao="1.00"><dados>value</dados></NFeB2BFin>"#;
1788        let result = attach_b2b(nfe_proc, b2b, None).unwrap();
1789        assert!(result.contains("<nfeProcB2B>"));
1790        assert!(result.contains("<dados>value</dados>"));
1791    }
1792
1793    // ── to_authorize: NFe path (line 428) ───────────────────────────────
1794
1795    #[test]
1796    fn to_authorize_dispatches_nfe() {
1797        let request = concat!(
1798            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
1799            r#"<DigestValue>abc</DigestValue>"#,
1800            r#"</infNFe></NFe>"#
1801        );
1802        let response = concat!(
1803            r#"<protNFe versao="4.00"><infProt>"#,
1804            r#"<cStat>100</cStat><xMotivo>OK</xMotivo>"#,
1805            r#"<digVal>abc</digVal>"#,
1806            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
1807            r#"</infProt></protNFe>"#
1808        );
1809        let result = to_authorize(request, response).unwrap();
1810        assert!(result.contains("<nfeProc"));
1811    }
1812
1813    // ── to_authorize: envEvento path (line 430) ─────────────────────────
1814
1815    #[test]
1816    fn to_authorize_dispatches_env_evento() {
1817        let request = concat!(
1818            r#"<envEvento><idLote>1</idLote>"#,
1819            r#"<evento versao="1.00"><infEvento>"#,
1820            r#"<tpEvento>110110</tpEvento>"#,
1821            r#"</infEvento></evento></envEvento>"#
1822        );
1823        let response = concat!(
1824            r#"<retEnvEvento><idLote>1</idLote>"#,
1825            r#"<retEvento versao="1.00"><infEvento>"#,
1826            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
1827            r#"<tpEvento>110110</tpEvento>"#,
1828            r#"</infEvento></retEvento></retEnvEvento>"#
1829        );
1830        let result = to_authorize(request, response).unwrap();
1831        assert!(result.contains("<procEventoNFe"));
1832    }
1833
1834    // ── to_authorize: inutNFe path (line 432) ───────────────────────────
1835
1836    #[test]
1837    fn to_authorize_dispatches_inut_nfe() {
1838        let request = concat!(
1839            r#"<inutNFe versao="4.00"><infInut>"#,
1840            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1841            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1842            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1843            r#"</infInut></inutNFe>"#
1844        );
1845        let response = concat!(
1846            r#"<retInutNFe versao="4.00"><infInut>"#,
1847            r#"<cStat>102</cStat><xMotivo>Inutilizacao homologada</xMotivo>"#,
1848            r#"<tpAmb>2</tpAmb><cUF>35</cUF><ano>26</ano>"#,
1849            r#"<CNPJ>12345678000199</CNPJ><mod>55</mod><serie>1</serie>"#,
1850            r#"<nNFIni>1</nNFIni><nNFFin>10</nNFFin>"#,
1851            r#"</infInut></retInutNFe>"#
1852        );
1853        let result = to_authorize(request, response).unwrap();
1854        assert!(result.contains("<ProcInutNFe"));
1855    }
1856
1857    // ── contains_xml_tag: tag at very end of string (line 446) ──────────
1858
1859    #[test]
1860    fn contains_xml_tag_at_end_of_string() {
1861        // Tag pattern at the very end, after >= xml.len() → true
1862        assert!(contains_xml_tag("<NFe", "NFe"));
1863    }
1864
1865    // ── strip_newlines helper tests ─────────────────────────────────────
1866
1867    #[test]
1868    fn strip_newlines_removes_newlines_and_cr() {
1869        assert_eq!(strip_newlines("a\nb\rc\r\nd"), "abcd");
1870    }
1871
1872    #[test]
1873    fn strip_newlines_removes_literal_backslash_s() {
1874        assert_eq!(strip_newlines("abc\\sdef"), "abcdef");
1875    }
1876
1877    #[test]
1878    fn strip_newlines_preserves_normal_content() {
1879        assert_eq!(strip_newlines("<tag>value</tag>"), "<tag>value</tag>");
1880    }
1881}