Skip to main content

fiscal_core/complement/
cancellation.rs

1//! Attach cancellation event response to an authorized `<nfeProc>` XML.
2
3use crate::FiscalError;
4use crate::xml_utils::extract_xml_tag_value;
5
6use super::helpers::{dom_reserialize_nfe_proc, extract_all_tags, extract_tag};
7
8/// Cancellation event type code (`110111`).
9const EVT_CANCELA: &str = "110111";
10/// Cancellation by substitution event type code (`110112`).
11const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
12
13/// Valid status codes for cancellation event matching.
14///
15/// - `135` — Event registered and linked
16/// - `136` — Event registered but not linked
17/// - `155` — Already cancelled (late)
18const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
19
20/// Attach a cancellation event response to an authorized `<nfeProc>` XML,
21/// marking the NF-e as locally cancelled.
22///
23/// This mirrors the PHP `Complements::cancelRegister()` method. The function
24/// searches the `cancel_event_xml` for `<retEvento>` elements whose:
25/// - `cStat` is in `[135, 136, 155]` (valid cancellation statuses)
26/// - `tpEvento` is `110111` (cancellation) or `110112` (cancellation by substitution)
27/// - `chNFe` matches the access key in the authorized NF-e's `<protNFe>`
28///
29/// When a matching `<retEvento>` is found, it is appended inside the
30/// `<nfeProc>` element (before the closing `</nfeProc>` tag).
31///
32/// If no matching cancellation event is found, the original NF-e XML is
33/// returned unchanged (same behavior as the PHP implementation).
34///
35/// # Arguments
36///
37/// * `nfe_proc_xml` - The authorized NF-e XML containing `<nfeProc>` with `<protNFe>`.
38/// * `cancel_event_xml` - The SEFAZ cancellation event response XML containing `<retEvento>`.
39///
40/// # Errors
41///
42/// Returns [`FiscalError::XmlParsing`] if:
43/// - The `nfe_proc_xml` does not contain `<protNFe>` (not an authorized NF-e)
44/// - The `<protNFe>` does not contain `<chNFe>`
45pub fn attach_cancellation(
46    nfe_proc_xml: &str,
47    cancel_event_xml: &str,
48) -> Result<String, FiscalError> {
49    // Validate the NF-e has a protNFe with a chNFe
50    let prot_nfe = extract_tag(nfe_proc_xml, "protNFe").ok_or_else(|| {
51        FiscalError::XmlParsing(
52            "Could not find <protNFe> in NF-e XML — is this an authorized NF-e?".into(),
53        )
54    })?;
55
56    let ch_nfe = extract_xml_tag_value(&prot_nfe, "chNFe")
57        .ok_or_else(|| FiscalError::XmlParsing("Could not find <chNFe> inside <protNFe>".into()))?;
58
59    // Search for matching retEvento in the cancellation XML
60    let ret_eventos = extract_all_tags(cancel_event_xml, "retEvento");
61
62    let mut matched_ret_evento: Option<&str> = None;
63
64    for ret_evento in &ret_eventos {
65        let c_stat = match extract_xml_tag_value(ret_evento, "cStat") {
66            Some(v) => v,
67            None => continue,
68        };
69        let tp_evento = match extract_xml_tag_value(ret_evento, "tpEvento") {
70            Some(v) => v,
71            None => continue,
72        };
73        let ch_nfe_evento = match extract_xml_tag_value(ret_evento, "chNFe") {
74            Some(v) => v,
75            None => continue,
76        };
77
78        if VALID_CANCEL_STATUSES.contains(&c_stat.as_str())
79            && (tp_evento == EVT_CANCELA || tp_evento == EVT_CANCELA_SUBSTITUICAO)
80            && ch_nfe_evento == ch_nfe
81        {
82            matched_ret_evento = Some(ret_evento.as_str());
83            break;
84        }
85    }
86
87    // Re-serialize via DOM-like logic to match PHP DOMDocument::saveXML().
88    // PHP always re-serializes even when no match is found — reordering
89    // xmlns before versao and emitting an XML declaration + newline.
90    dom_reserialize_nfe_proc(nfe_proc_xml, matched_ret_evento)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    // ── attach_cancellation tests ─────────────────────────────────────
98
99    #[test]
100    fn attach_cancellation_appends_matching_ret_evento() {
101        let nfe_proc = concat!(
102            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
103            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
104            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
105            r#"<ide/></infNFe></NFe>"#,
106            r#"<protNFe versao="4.00"><infProt>"#,
107            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
108            r#"<cStat>100</cStat><nProt>135220000009921</nProt>"#,
109            r#"</infProt></protNFe>"#,
110            r#"</nfeProc>"#
111        );
112
113        let cancel_xml = concat!(
114            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
115            r#"<cStat>135</cStat>"#,
116            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
117            r#"<tpEvento>110111</tpEvento>"#,
118            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
119            r#"<nProt>135220000009999</nProt>"#,
120            r#"</infEvento></retEvento></retEnvEvento>"#
121        );
122
123        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
124
125        // Must contain the retEvento inside nfeProc
126        assert!(
127            result.contains("<retEvento"),
128            "Result should contain <retEvento>"
129        );
130        assert!(
131            result.contains("<tpEvento>110111</tpEvento>"),
132            "Result should contain cancellation event type"
133        );
134        // The retEvento should appear before </nfeProc>
135        let ret_pos = result.find("<retEvento").unwrap();
136        let close_pos = result.rfind("</nfeProc>").unwrap();
137        assert!(ret_pos < close_pos, "retEvento should be before </nfeProc>");
138        // Original content should be preserved
139        assert!(result.contains("<protNFe"));
140        assert!(result.contains("<NFe>"));
141    }
142
143    #[test]
144    fn attach_cancellation_ignores_non_matching_ch_nfe() {
145        let nfe_proc = concat!(
146            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
147            r#"<NFe/>"#,
148            r#"<protNFe versao="4.00"><infProt>"#,
149            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
150            r#"<cStat>100</cStat>"#,
151            r#"</infProt></protNFe>"#,
152            r#"</nfeProc>"#
153        );
154
155        let cancel_xml = concat!(
156            r#"<retEvento versao="1.00"><infEvento>"#,
157            r#"<cStat>135</cStat>"#,
158            r#"<tpEvento>110111</tpEvento>"#,
159            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
160            r#"<nProt>135220000009999</nProt>"#,
161            r#"</infEvento></retEvento>"#
162        );
163
164        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
165        // No matching chNFe — should NOT contain retEvento, but still
166        // re-serialized like PHP (declaration + xmlns before versao)
167        assert!(
168            !result.contains("<retEvento"),
169            "Should not contain retEvento"
170        );
171        assert!(
172            result.starts_with("<?xml version=\"1.0\"?>\n"),
173            "Should have XML declaration (no encoding since input had none)"
174        );
175        assert!(
176            result
177                .contains(r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#),
178            "Should reorder xmlns before versao"
179        );
180    }
181
182    #[test]
183    fn attach_cancellation_ignores_wrong_tp_evento() {
184        let nfe_proc = concat!(
185            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
186            r#"<NFe/>"#,
187            r#"<protNFe versao="4.00"><infProt>"#,
188            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
189            r#"<cStat>100</cStat>"#,
190            r#"</infProt></protNFe>"#,
191            r#"</nfeProc>"#
192        );
193
194        let cancel_xml = concat!(
195            r#"<retEvento versao="1.00"><infEvento>"#,
196            r#"<cStat>135</cStat>"#,
197            r#"<tpEvento>110110</tpEvento>"#, // CCe, not cancellation
198            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
199            r#"<nProt>135220000009999</nProt>"#,
200            r#"</infEvento></retEvento>"#
201        );
202
203        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
204        // Wrong tpEvento — should NOT contain retEvento
205        assert!(
206            !result.contains("<retEvento"),
207            "Should not contain retEvento"
208        );
209        assert!(
210            result.starts_with("<?xml version=\"1.0\"?>\n"),
211            "Should have XML declaration"
212        );
213    }
214
215    #[test]
216    fn attach_cancellation_ignores_rejected_status() {
217        let nfe_proc = concat!(
218            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
219            r#"<NFe/>"#,
220            r#"<protNFe versao="4.00"><infProt>"#,
221            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
222            r#"<cStat>100</cStat>"#,
223            r#"</infProt></protNFe>"#,
224            r#"</nfeProc>"#
225        );
226
227        let cancel_xml = concat!(
228            r#"<retEvento versao="1.00"><infEvento>"#,
229            r#"<cStat>573</cStat>"#, // Rejected status
230            r#"<tpEvento>110111</tpEvento>"#,
231            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
232            r#"<nProt>135220000009999</nProt>"#,
233            r#"</infEvento></retEvento>"#
234        );
235
236        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
237        // Rejected status — should NOT contain retEvento
238        assert!(
239            !result.contains("<retEvento"),
240            "Should not contain retEvento"
241        );
242        assert!(
243            result.starts_with("<?xml version=\"1.0\"?>\n"),
244            "Should have XML declaration"
245        );
246    }
247
248    #[test]
249    fn attach_cancellation_accepts_status_155() {
250        let nfe_proc = concat!(
251            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
252            r#"<NFe/>"#,
253            r#"<protNFe versao="4.00"><infProt>"#,
254            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
255            r#"<cStat>100</cStat>"#,
256            r#"</infProt></protNFe>"#,
257            r#"</nfeProc>"#
258        );
259
260        let cancel_xml = concat!(
261            r#"<retEvento versao="1.00"><infEvento>"#,
262            r#"<cStat>155</cStat>"#,
263            r#"<tpEvento>110111</tpEvento>"#,
264            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
265            r#"<nProt>135220000009999</nProt>"#,
266            r#"</infEvento></retEvento>"#
267        );
268
269        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
270        assert!(result.contains("<retEvento"));
271    }
272
273    #[test]
274    fn attach_cancellation_accepts_substituicao_110112() {
275        let nfe_proc = concat!(
276            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
277            r#"<NFe/>"#,
278            r#"<protNFe versao="4.00"><infProt>"#,
279            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
280            r#"<cStat>100</cStat>"#,
281            r#"</infProt></protNFe>"#,
282            r#"</nfeProc>"#
283        );
284
285        let cancel_xml = concat!(
286            r#"<retEvento versao="1.00"><infEvento>"#,
287            r#"<cStat>135</cStat>"#,
288            r#"<tpEvento>110112</tpEvento>"#,
289            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
290            r#"<nProt>135220000009999</nProt>"#,
291            r#"</infEvento></retEvento>"#
292        );
293
294        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
295        assert!(
296            result.contains("<tpEvento>110112</tpEvento>"),
297            "Should accept cancellation by substitution"
298        );
299    }
300
301    #[test]
302    fn attach_cancellation_rejects_missing_prot_nfe() {
303        let nfe_xml = "<NFe><infNFe/></NFe>";
304        let cancel_xml = "<retEvento/>";
305        let err = attach_cancellation(nfe_xml, cancel_xml).unwrap_err();
306        assert!(matches!(err, FiscalError::XmlParsing(_)));
307    }
308
309    #[test]
310    fn attach_cancellation_rejects_missing_ch_nfe_in_prot() {
311        let nfe_proc = concat!(
312            r#"<nfeProc><protNFe versao="4.00"><infProt>"#,
313            r#"<cStat>100</cStat>"#,
314            r#"</infProt></protNFe></nfeProc>"#
315        );
316        let cancel_xml = "<retEvento/>";
317        let err = attach_cancellation(nfe_proc, cancel_xml).unwrap_err();
318        assert!(matches!(err, FiscalError::XmlParsing(_)));
319    }
320
321    /// Byte-for-byte parity test: Rust output must match what PHP
322    /// `Complements::cancelRegister()` produces for the same inputs.
323    ///
324    /// PHP uses `DOMDocument::saveXML()` which:
325    /// 1. Emits `<?xml version="1.0" encoding="UTF-8"?>` + `\n`
326    /// 2. Reorders `xmlns` before `versao` on `<nfeProc>`
327    /// 3. Appends `<retEvento>` as last child of `<nfeProc>`
328    #[test]
329    fn attach_cancellation_parity_with_php() {
330        // Input: authorized nfeProc (as produced by join_xml / PHP join())
331        let nfe_proc = concat!(
332            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
333            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
334            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
335            r#"<ide/></infNFe></NFe>"#,
336            r#"<protNFe versao="4.00"><infProt>"#,
337            r#"<digVal>abc</digVal>"#,
338            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
339            r#"<cStat>100</cStat>"#,
340            r#"<xMotivo>Autorizado</xMotivo>"#,
341            r#"<nProt>135220000009921</nProt>"#,
342            r#"</infProt></protNFe>"#,
343            r#"</nfeProc>"#
344        );
345
346        let cancel_xml = concat!(
347            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
348            r#"<cStat>135</cStat>"#,
349            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
350            r#"<tpEvento>110111</tpEvento>"#,
351            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
352            r#"<nProt>135220000009999</nProt>"#,
353            r#"</infEvento></retEvento></retEnvEvento>"#
354        );
355
356        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
357
358        // Expected output from PHP DOMDocument::saveXML():
359        // - declaration with encoding="UTF-8" followed by \n
360        // - <nfeProc xmlns="..." versao="..."> (xmlns first)
361        // - inner content unchanged
362        // - <retEvento> appended before </nfeProc>
363        let expected = concat!(
364            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
365            r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
366            r#"<NFe><infNFe versao="4.00" Id="NFe35260112345678000199550010000000011123456780">"#,
367            r#"<ide/></infNFe></NFe>"#,
368            r#"<protNFe versao="4.00"><infProt>"#,
369            r#"<digVal>abc</digVal>"#,
370            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
371            r#"<cStat>100</cStat>"#,
372            r#"<xMotivo>Autorizado</xMotivo>"#,
373            r#"<nProt>135220000009921</nProt>"#,
374            r#"</infProt></protNFe>"#,
375            r#"<retEvento versao="1.00"><infEvento>"#,
376            r#"<cStat>135</cStat>"#,
377            r#"<xMotivo>Evento registrado e vinculado a NF-e</xMotivo>"#,
378            r#"<tpEvento>110111</tpEvento>"#,
379            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
380            r#"<nProt>135220000009999</nProt>"#,
381            r#"</infEvento></retEvento>"#,
382            "</nfeProc>\n"
383        );
384
385        assert_eq!(result, expected);
386    }
387
388    /// Parity test for no-match case: PHP still re-serializes through saveXML().
389    #[test]
390    fn attach_cancellation_no_match_still_reserializes_like_php() {
391        let nfe_proc = concat!(
392            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
393            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
394            r#"<NFe/>"#,
395            r#"<protNFe versao="4.00"><infProt>"#,
396            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
397            r#"<cStat>100</cStat>"#,
398            r#"</infProt></protNFe>"#,
399            r#"</nfeProc>"#
400        );
401
402        let cancel_xml = concat!(
403            r#"<retEnvEvento><retEvento versao="1.00"><infEvento>"#,
404            r#"<cStat>135</cStat>"#,
405            r#"<tpEvento>110111</tpEvento>"#,
406            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
407            r#"<nProt>135220000009999</nProt>"#,
408            r#"</infEvento></retEvento></retEnvEvento>"#
409        );
410
411        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
412
413        // PHP DOMDocument::saveXML() output (no retEvento appended):
414        let expected = concat!(
415            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
416            r#"<nfeProc xmlns="http://www.portalfiscal.inf.br/nfe" versao="4.00">"#,
417            r#"<NFe/>"#,
418            r#"<protNFe versao="4.00"><infProt>"#,
419            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
420            r#"<cStat>100</cStat>"#,
421            r#"</infProt></protNFe>"#,
422            "</nfeProc>\n"
423        );
424
425        assert_eq!(result, expected);
426    }
427
428    #[test]
429    fn attach_cancellation_picks_first_matching_from_multiple_ret_eventos() {
430        let nfe_proc = concat!(
431            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
432            r#"<NFe/>"#,
433            r#"<protNFe versao="4.00"><infProt>"#,
434            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
435            r#"<cStat>100</cStat>"#,
436            r#"</infProt></protNFe>"#,
437            r#"</nfeProc>"#
438        );
439
440        let cancel_xml = concat!(
441            r#"<retEnvEvento>"#,
442            // First: wrong chNFe
443            r#"<retEvento versao="1.00"><infEvento>"#,
444            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
445            r#"<chNFe>99999999999999999999999999999999999999999999</chNFe>"#,
446            r#"<nProt>111111111111111</nProt>"#,
447            r#"</infEvento></retEvento>"#,
448            // Second: correct match
449            r#"<retEvento versao="1.00"><infEvento>"#,
450            r#"<cStat>135</cStat><tpEvento>110111</tpEvento>"#,
451            r#"<chNFe>35260112345678000199550010000000011123456780</chNFe>"#,
452            r#"<nProt>222222222222222</nProt>"#,
453            r#"</infEvento></retEvento>"#,
454            r#"</retEnvEvento>"#
455        );
456
457        let result = attach_cancellation(nfe_proc, cancel_xml).unwrap();
458        assert!(result.contains("<nProt>222222222222222</nProt>"));
459        // Should only have one retEvento (the matching one)
460        assert_eq!(result.matches("<retEvento").count(), 1);
461    }
462}