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