1use crate::FiscalError;
4use crate::xml_utils::extract_xml_tag_value;
5
6use super::helpers::{dom_reserialize_nfe_proc, extract_all_tags, extract_tag};
7
8const EVT_CANCELA: &str = "110111";
10const EVT_CANCELA_SUBSTITUICAO: &str = "110112";
12
13const VALID_CANCEL_STATUSES: &[&str] = &["135", "136", "155"];
19
20pub fn attach_cancellation(
46 nfe_proc_xml: &str,
47 cancel_event_xml: &str,
48) -> Result<String, FiscalError> {
49 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 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 dom_reserialize_nfe_proc(nfe_proc_xml, matched_ret_evento)
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[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 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 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 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 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>"#, 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 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>"#, 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 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 #[test]
329 fn attach_cancellation_parity_with_php() {
330 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 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 #[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 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 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 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 assert_eq!(result.matches("<retEvento").count(), 1);
461 }
462}