Skip to main content

fiscal_core/complement/
event.rs

1//! Attach event protocol response to the event request (`<procEventoNFe>` wrapper).
2
3use crate::FiscalError;
4use crate::xml_utils::extract_xml_tag_value;
5
6use super::helpers::{DEFAULT_VERSION, extract_attribute, extract_tag, join_xml};
7
8/// Cancellation event type code (`110111`).
9const EVT_CANCELA: &str = "110111";
10
11/// Attach an event protocol response to the event request,
12/// producing the `<procEventoNFe>` wrapper.
13///
14/// Extracts `<evento>` from `request_xml` and `<retEvento>` from
15/// `response_xml`, validates the event status, and joins them
16/// into a `<procEventoNFe>` document.
17///
18/// # Errors
19///
20/// Returns [`FiscalError::XmlParsing`] if:
21/// - Either input is empty
22/// - The `<evento>` tag is missing from `request_xml`
23/// - The `<retEvento>` tag is missing from `response_xml`
24/// - The `<idLote>` tag is missing from `request_xml` or `response_xml`
25/// - The `idLote` values differ between request and response
26///
27/// Returns [`FiscalError::SefazRejection`] if the event status code
28/// is not valid (135, 136, or 155 for cancellation only).
29pub fn attach_event_protocol(request_xml: &str, response_xml: &str) -> Result<String, FiscalError> {
30    if request_xml.is_empty() {
31        return Err(FiscalError::XmlParsing("Event request XML is empty".into()));
32    }
33    if response_xml.is_empty() {
34        return Err(FiscalError::XmlParsing(
35            "Event response XML is empty".into(),
36        ));
37    }
38
39    let evento_content = extract_tag(request_xml, "evento").ok_or_else(|| {
40        FiscalError::XmlParsing("Could not find <evento> tag in request XML".into())
41    })?;
42
43    let ret_evento_content = extract_tag(response_xml, "retEvento").ok_or_else(|| {
44        FiscalError::XmlParsing("Could not find <retEvento> tag in response XML".into())
45    })?;
46
47    // Get version from the evento tag
48    let version = extract_attribute(&evento_content, "evento", "versao")
49        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
50
51    // Validate event status FIRST (PHP validates cStat before idLote)
52    let c_stat = extract_xml_tag_value(&ret_evento_content, "cStat").unwrap_or_default();
53    let tp_evento = extract_xml_tag_value(&ret_evento_content, "tpEvento").unwrap_or_default();
54
55    // Build the valid statuses list: 135, 136 always; 155 only for cancellation
56    let mut valid_statuses: Vec<&str> = vec!["135", "136"];
57    if tp_evento == EVT_CANCELA {
58        valid_statuses.push("155");
59    }
60
61    if !valid_statuses.contains(&c_stat.as_str()) {
62        let x_motivo = extract_xml_tag_value(&ret_evento_content, "xMotivo").unwrap_or_default();
63        return Err(FiscalError::SefazRejection {
64            code: c_stat,
65            message: x_motivo,
66        });
67    }
68
69    // Validate idLote is present in both request and response, then compare.
70    // PHP addEnvEventoProtocol accesses ->nodeValue directly on idLote;
71    // if the tag is absent, PHP throws a fatal error.
72    let req_id_lote = extract_xml_tag_value(request_xml, "idLote")
73        .ok_or_else(|| FiscalError::XmlParsing("idLote not found in request XML".into()))?;
74    let ret_id_lote = extract_xml_tag_value(response_xml, "idLote")
75        .ok_or_else(|| FiscalError::XmlParsing("idLote not found in response XML".into()))?;
76    if req_id_lote != ret_id_lote {
77        return Err(FiscalError::XmlParsing(
78            "Os números de lote dos documentos são diferentes".into(),
79        ));
80    }
81
82    Ok(join_xml(
83        &evento_content,
84        &ret_evento_content,
85        "procEventoNFe",
86        &version,
87    ))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn attach_event_protocol_empty_request() {
96        let err = attach_event_protocol("", "<retEvento/>").unwrap_err();
97        assert!(matches!(err, FiscalError::XmlParsing(_)));
98    }
99
100    #[test]
101    fn attach_event_protocol_empty_response() {
102        let err = attach_event_protocol("<evento/>", "").unwrap_err();
103        assert!(matches!(err, FiscalError::XmlParsing(_)));
104    }
105
106    #[test]
107    fn attach_event_protocol_missing_evento() {
108        let err = attach_event_protocol(
109            "<other/>",
110            "<retEvento><infEvento><cStat>135</cStat></infEvento></retEvento>",
111        )
112        .unwrap_err();
113        assert!(matches!(err, FiscalError::XmlParsing(_)));
114    }
115
116    #[test]
117    fn attach_event_protocol_missing_ret_evento() {
118        let err =
119            attach_event_protocol(r#"<evento versao="1.00"><infEvento/></evento>"#, "<other/>")
120                .unwrap_err();
121        assert!(matches!(err, FiscalError::XmlParsing(_)));
122    }
123
124    #[test]
125    fn attach_event_protocol_rejected_status() {
126        let err = attach_event_protocol(
127            r#"<evento versao="1.00"><infEvento/></evento>"#,
128            r#"<retEvento><infEvento><cStat>999</cStat><xMotivo>Rejeitado</xMotivo></infEvento></retEvento>"#,
129        )
130        .unwrap_err();
131        assert!(matches!(err, FiscalError::SefazRejection { .. }));
132    }
133
134    #[test]
135    fn attach_event_protocol_success() {
136        let request = concat!(
137            r#"<envEvento><idLote>100</idLote>"#,
138            r#"<evento versao="1.00"><infEvento Id="ID1234"/></evento>"#,
139            r#"</envEvento>"#
140        );
141        let response = concat!(
142            r#"<retEnvEvento><idLote>100</idLote>"#,
143            r#"<retEvento><infEvento><cStat>135</cStat>"#,
144            r#"<xMotivo>Evento registrado</xMotivo>"#,
145            r#"</infEvento></retEvento></retEnvEvento>"#
146        );
147        let result = attach_event_protocol(request, response).unwrap();
148        assert!(result.contains("<procEventoNFe"));
149        assert!(result.contains("<evento"));
150        assert!(result.contains("<retEvento>"));
151    }
152
153    // ── attach_event_protocol: idLote mismatch (lines 277-278) ──────────
154
155    #[test]
156    fn attach_event_protocol_id_lote_mismatch() {
157        let request = concat!(
158            r#"<envEvento><idLote>100</idLote>"#,
159            r#"<evento versao="1.00"><infEvento>"#,
160            r#"<tpEvento>110110</tpEvento>"#,
161            r#"</infEvento></evento></envEvento>"#
162        );
163        let response = concat!(
164            r#"<retEnvEvento><idLote>999</idLote>"#,
165            r#"<retEvento versao="1.00"><infEvento>"#,
166            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
167            r#"<tpEvento>110110</tpEvento>"#,
168            r#"</infEvento></retEvento></retEnvEvento>"#
169        );
170        let err = attach_event_protocol(request, response).unwrap_err();
171        match err {
172            FiscalError::XmlParsing(msg) => {
173                assert!(
174                    msg.contains("lote"),
175                    "Expected lote mismatch error, got: {msg}"
176                );
177            }
178            other => panic!("Expected XmlParsing, got {:?}", other),
179        }
180    }
181
182    // ── attach_event_protocol: cStat validated before idLote (PHP parity) ──
183
184    #[test]
185    fn attach_event_protocol_both_invalid_reports_cstat_first() {
186        // When BOTH cStat is invalid AND idLote mismatches, the error
187        // must be about cStat (SefazRejection), not about idLote,
188        // matching PHP addEnvEventoProtocol validation order.
189        let request = concat!(
190            r#"<envEvento><idLote>100</idLote>"#,
191            r#"<evento versao="1.00"><infEvento>"#,
192            r#"<tpEvento>110110</tpEvento>"#,
193            r#"</infEvento></evento></envEvento>"#
194        );
195        let response = concat!(
196            r#"<retEnvEvento><idLote>999</idLote>"#,
197            r#"<retEvento versao="1.00"><infEvento>"#,
198            r#"<cStat>573</cStat><xMotivo>Duplicidade de evento</xMotivo>"#,
199            r#"<tpEvento>110110</tpEvento>"#,
200            r#"</infEvento></retEvento></retEnvEvento>"#
201        );
202        let err = attach_event_protocol(request, response).unwrap_err();
203        match err {
204            FiscalError::SefazRejection { code, message } => {
205                assert_eq!(code, "573");
206                assert_eq!(message, "Duplicidade de evento");
207            }
208            other => panic!("Expected SefazRejection (cStat first), got {:?}", other),
209        }
210    }
211
212    // ── attach_event_protocol: missing idLote ─────────────────────────
213
214    #[test]
215    fn attach_event_protocol_missing_id_lote_in_request() {
216        let request = concat!(
217            r#"<envEvento>"#,
218            r#"<evento versao="1.00"><infEvento>"#,
219            r#"<tpEvento>110110</tpEvento>"#,
220            r#"</infEvento></evento></envEvento>"#
221        );
222        let response = concat!(
223            r#"<retEnvEvento><idLote>100</idLote>"#,
224            r#"<retEvento versao="1.00"><infEvento>"#,
225            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
226            r#"<tpEvento>110110</tpEvento>"#,
227            r#"</infEvento></retEvento></retEnvEvento>"#
228        );
229        let err = attach_event_protocol(request, response).unwrap_err();
230        match err {
231            FiscalError::XmlParsing(msg) => {
232                assert_eq!(msg, "idLote not found in request XML");
233            }
234            other => panic!("Expected XmlParsing, got {:?}", other),
235        }
236    }
237
238    #[test]
239    fn attach_event_protocol_missing_id_lote_in_response() {
240        let request = concat!(
241            r#"<envEvento><idLote>100</idLote>"#,
242            r#"<evento versao="1.00"><infEvento>"#,
243            r#"<tpEvento>110110</tpEvento>"#,
244            r#"</infEvento></evento></envEvento>"#
245        );
246        let response = concat!(
247            r#"<retEnvEvento>"#,
248            r#"<retEvento versao="1.00"><infEvento>"#,
249            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
250            r#"<tpEvento>110110</tpEvento>"#,
251            r#"</infEvento></retEvento></retEnvEvento>"#
252        );
253        let err = attach_event_protocol(request, response).unwrap_err();
254        match err {
255            FiscalError::XmlParsing(msg) => {
256                assert_eq!(msg, "idLote not found in response XML");
257            }
258            other => panic!("Expected XmlParsing, got {:?}", other),
259        }
260    }
261
262    #[test]
263    fn attach_event_protocol_missing_id_lote_in_both() {
264        let request = concat!(
265            r#"<envEvento>"#,
266            r#"<evento versao="1.00"><infEvento>"#,
267            r#"<tpEvento>110110</tpEvento>"#,
268            r#"</infEvento></evento></envEvento>"#
269        );
270        let response = concat!(
271            r#"<retEnvEvento>"#,
272            r#"<retEvento versao="1.00"><infEvento>"#,
273            r#"<cStat>135</cStat><xMotivo>OK</xMotivo>"#,
274            r#"<tpEvento>110110</tpEvento>"#,
275            r#"</infEvento></retEvento></retEnvEvento>"#
276        );
277        let err = attach_event_protocol(request, response).unwrap_err();
278        match err {
279            FiscalError::XmlParsing(msg) => {
280                assert_eq!(msg, "idLote not found in request XML");
281            }
282            other => panic!("Expected XmlParsing, got {:?}", other),
283        }
284    }
285}