Skip to main content

ecr17_protocol/
response.rs

1//! Parsers for ECR17 terminal *response* application messages.
2//!
3//! Each parser takes the application payload as text — the bytes between `STX` and `ETX`
4//! ([`crate::codec::DecodedPacket::payload`], a `Vec<u8>`) decoded to a `&str` by the
5//! caller — and returns a raw struct whose fields sit at the exact 1-based spec offsets.
6//! Fields are mostly the raw strings as received, plus a few derived conveniences (the
7//! `outcome` [`TransactionOutcome`], the numeric `status`, and boolean flags). The
8//! `client` layer maps these raw structs onto the fully typed [`crate::types`] results
9//! (enum/amount/date conversions).
10//!
11//! Parsing is **defensive**: a field starting beyond the payload comes back empty (and a
12//! partial field is clamped) rather than panicking, so a short/truncated response degrades
13//! gracefully. Port of the reference C++ `Ecr17Response`.
14//!
15//! ## Known limitations (carried from the reference; validate on a real terminal)
16//! - DCC is parsed only for the uppercase `'V'` payment response. Pre-auth **closure** DCC
17//!   responses reportedly use a lowercase `'v'` with a different DCC offset; that variant
18//!   is not parsed here (matches the reference) and would need real-terminal data to add
19//!   safely.
20//! - The shared payment-family parser does not extract a reversal **action code** (the
21//!   reference `PaymentResponse` has no such field), so `ReversalResult.action_code` stays
22//!   empty when a reversal is parsed through this path.
23
24use crate::types::TransactionOutcome;
25
26/// 1-based field extractor. Returns `""` if the field starts beyond the payload; clamps the
27/// length to whatever bytes are actually present. Char-boundary safe (ECR17 fixed fields
28/// are ASCII, so this never trims mid-character).
29fn at(p: &str, pos1: usize, len: usize) -> &str {
30    if pos1 == 0 || pos1 > p.len() {
31        return "";
32    }
33    let i = pos1 - 1;
34    let end = (i + len).min(p.len());
35    p.get(i..end).unwrap_or("")
36}
37
38fn trim_right(s: &str) -> String {
39    s.trim_end_matches(' ').to_string()
40}
41
42/// Extracts the value of a Nexi VAS XML param: `<p k="KEY">value</p>`.
43fn xml_value(xml: &str, key: &str) -> String {
44    let needle = format!("\"{key}\">");
45    let Some(start) = xml.find(&needle) else {
46        return String::new();
47    };
48    let from = start + needle.len();
49    let rest = &xml[from..];
50    let value = match rest.find('<') {
51        Some(end) => &rest[..end],
52        None => rest,
53    };
54    value.trim().to_string()
55}
56
57/// Maps the raw 2-digit result code to a [`TransactionOutcome`].
58#[must_use]
59pub fn outcome_from_code(code: &str) -> TransactionOutcome {
60    match code {
61        "00" => TransactionOutcome::Ok,
62        "01" => TransactionOutcome::Ko,
63        "05" => TransactionOutcome::CardNotPresent,
64        "09" => TransactionOutcome::UnknownTag,
65        _ => TransactionOutcome::Unknown,
66    }
67}
68
69/// Optional DCC / currency-exchange block (parsed from a `'V'` response). Named `DccInfo`
70/// to mirror the reference; the `client` maps it to [`crate::types::CurrencyExchange`].
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct DccInfo {
73    /// Whether DCC was applied (flag byte `== "1"`).
74    pub applied: bool,
75    /// Rate (8 digits, 4 decimals), raw.
76    pub rate: String,
77    /// Currency code (alpha-3), raw.
78    pub currency_code: String,
79    /// Converted amount (12 digits), raw.
80    pub amount: String,
81    /// Decimal precision, raw.
82    pub precision: String,
83}
84
85/// Raw payment-family response (`'E'` without DCC, `'V'` with DCC). Reused for reversal /
86/// card verification / pre-auth closure which share the layout.
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct PaymentResponse {
89    /// Normalized outcome.
90    pub outcome: TransactionOutcome,
91    /// Raw result code (`"00"`/`"01"`/`"05"`/`"09"`).
92    pub result_code: String,
93    /// PAN (positive), raw.
94    pub pan: String,
95    /// Transaction/entry type (positive), raw `"ICC"`/`"MAG"`/…
96    pub transaction_type: String,
97    /// Authorization code (positive), raw.
98    pub auth_code: String,
99    /// Host date/time (positive), raw `DDDHHMM`.
100    pub host_date_time: String,
101    /// Error description (negative), raw.
102    pub error_description: String,
103    /// Card type (common), raw `"1"`/`"2"`/`"3"`.
104    pub card_type: String,
105    /// Acquirer id (common), raw.
106    pub acquirer_id: String,
107    /// STAN (common), raw.
108    pub stan: String,
109    /// Online id (common), raw.
110    pub online_id: String,
111    /// DCC block (only when a `'V'` response carried one).
112    pub currency: DccInfo,
113}
114
115/// Raw status response (`'s'`).
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct StatusResponse {
118    /// Terminal id, raw.
119    pub terminal_id: String,
120    /// Date/time, raw `DDMMYYhhmm`.
121    pub date_time_raw: String,
122    /// Status code `0..=6`, or `-1` if unknown/missing.
123    pub status: i32,
124    /// Firmware/software release, raw.
125    pub software_release: String,
126}
127
128/// Raw totals response (`'T'`).
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct TotalsResponse {
131    /// Normalized outcome.
132    pub outcome: TransactionOutcome,
133    /// Raw result code.
134    pub result_code: String,
135    /// POS total (16 digits, cents), raw.
136    pub pos_total: String,
137}
138
139/// Raw close-session response (`'C'`).
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct CloseResponse {
142    /// Normalized outcome.
143    pub outcome: TransactionOutcome,
144    /// Raw result code.
145    pub result_code: String,
146    /// POS total (positive, 16 digits), raw.
147    pub pos_total: String,
148    /// Host total (positive, 16 digits), raw.
149    pub host_total: String,
150    /// Error description (negative), raw.
151    pub error_description: String,
152    /// Action code (negative), raw.
153    pub action_code: String,
154}
155
156/// Raw pre-auth response (`'e'`).
157#[derive(Debug, Clone, Default, PartialEq, Eq)]
158pub struct PreAuthResponse {
159    /// Normalized outcome.
160    pub outcome: TransactionOutcome,
161    /// Raw result code.
162    pub result_code: String,
163    /// PAN, raw.
164    pub pan: String,
165    /// Transaction/entry type, raw.
166    pub transaction_type: String,
167    /// Authorization code, raw.
168    pub auth_code: String,
169    /// Pre-authorized amount (8 digits, cents), raw.
170    pub pre_authorized_amount: String,
171    /// Pre-auth code (9 digits), raw.
172    pub pre_auth_code: String,
173    /// Action code, raw.
174    pub action_code: String,
175    /// Host date/time, raw.
176    pub host_date_time: String,
177    /// Error description, raw.
178    pub error_description: String,
179    /// Card type, raw.
180    pub card_type: String,
181    /// Acquirer id, raw.
182    pub acquirer_id: String,
183    /// STAN, raw.
184    pub stan: String,
185    /// Online id, raw.
186    pub online_id: String,
187}
188
189/// Raw VAS response (`'K'`).
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct VasResponse {
192    /// `RESPID` parsed from XML (`"0"` = OK); empty if absent.
193    pub response_id: String,
194    /// `RESPMSG`.
195    pub response_message: String,
196    /// `ORDER_ID`.
197    pub order_id: String,
198    /// Concatenation flag (`"1"` → more messages follow).
199    pub more_messages: bool,
200    /// 3-digit sequence id.
201    pub id_message: String,
202    /// The XML body of this message.
203    pub raw_xml: String,
204}
205
206/// Parses a payment-family response (`'E'` plain / `'V'` DCC).
207#[must_use]
208pub fn parse_payment(p: &str) -> PaymentResponse {
209    let mut r = PaymentResponse::default();
210    let dcc = at(p, 10, 1) == "V"; // message code 'E' (plain) or 'V' (DCC)
211
212    r.result_code = at(p, 11, 2).to_string();
213    r.outcome = outcome_from_code(&r.result_code);
214
215    if r.outcome == TransactionOutcome::Ko {
216        r.error_description = trim_right(at(p, 13, 24));
217    } else {
218        r.pan = at(p, 13, 19).to_string();
219        r.transaction_type = trim_right(at(p, 32, 3));
220        r.auth_code = trim_right(at(p, 35, 6));
221        r.host_date_time = at(p, 41, 7).to_string();
222    }
223
224    // Common to any response.
225    r.card_type = at(p, 48, 1).to_string();
226    r.acquirer_id = trim_right(at(p, 49, 11));
227    r.stan = at(p, 60, 6).to_string();
228    r.online_id = at(p, 66, 6).to_string();
229
230    if dcc {
231        r.currency.applied = at(p, 83, 1) == "1";
232        r.currency.rate = at(p, 84, 8).to_string();
233        r.currency.currency_code = trim_right(at(p, 92, 3));
234        r.currency.amount = at(p, 95, 12).to_string();
235        r.currency.precision = at(p, 107, 1).to_string();
236    }
237    r
238}
239
240/// Parses a status response (`'s'`).
241#[must_use]
242pub fn parse_status(p: &str) -> StatusResponse {
243    let s = at(p, 31, 1);
244    let status = s
245        .bytes()
246        .next()
247        .filter(u8::is_ascii_digit)
248        .map_or(-1, |b| i32::from(b - b'0'));
249    StatusResponse {
250        terminal_id: at(p, 1, 8).to_string(),
251        date_time_raw: at(p, 21, 10).to_string(),
252        status,
253        software_release: trim_right(at(p, 32, p.len())),
254    }
255}
256
257/// Parses a totals response (`'T'`).
258#[must_use]
259pub fn parse_totals(p: &str) -> TotalsResponse {
260    let result_code = at(p, 11, 2).to_string();
261    TotalsResponse {
262        outcome: outcome_from_code(&result_code),
263        result_code,
264        pos_total: at(p, 13, 16).to_string(),
265    }
266}
267
268/// Parses a close-session response (`'C'`).
269#[must_use]
270pub fn parse_close(p: &str) -> CloseResponse {
271    let mut r = CloseResponse {
272        result_code: at(p, 11, 2).to_string(),
273        ..Default::default()
274    };
275    r.outcome = outcome_from_code(&r.result_code);
276    if r.outcome == TransactionOutcome::Ok {
277        r.pos_total = at(p, 13, 16).to_string();
278        r.host_total = at(p, 29, 16).to_string();
279    } else {
280        r.error_description = trim_right(at(p, 13, 19));
281        r.action_code = at(p, 32, 3).to_string();
282    }
283    r
284}
285
286/// Parses a pre-auth response (`'e'`).
287#[must_use]
288pub fn parse_pre_auth(p: &str) -> PreAuthResponse {
289    let mut r = PreAuthResponse {
290        result_code: at(p, 11, 2).to_string(),
291        ..Default::default()
292    };
293    r.outcome = outcome_from_code(&r.result_code);
294    if r.outcome == TransactionOutcome::Ko {
295        r.error_description = trim_right(at(p, 13, 24));
296        r.action_code = at(p, 37, 3).to_string();
297    } else {
298        r.pan = at(p, 13, 19).to_string();
299        r.transaction_type = trim_right(at(p, 32, 3));
300        r.auth_code = trim_right(at(p, 35, 6));
301        r.pre_authorized_amount = at(p, 41, 8).to_string();
302        r.pre_auth_code = at(p, 49, 9).to_string();
303        r.action_code = at(p, 58, 3).to_string();
304        r.host_date_time = at(p, 61, 7).to_string();
305    }
306    // In the OK layout pre_authorized_amount occupies positions 41-48, so position 48 is
307    // the amount's last digit, NOT a card type. Only read card_type for the KO layout.
308    if r.outcome == TransactionOutcome::Ko {
309        r.card_type = at(p, 48, 1).to_string();
310    }
311    r.acquirer_id = trim_right(at(p, 72, 11));
312    r.stan = at(p, 83, 6).to_string();
313    r.online_id = at(p, 89, 6).to_string();
314    r
315}
316
317/// Parses a VAS response (`'K'`).
318#[must_use]
319pub fn parse_vas(p: &str) -> VasResponse {
320    let raw_xml = at(p, 27, p.len()).to_string();
321    VasResponse {
322        response_id: xml_value(&raw_xml, "RESPID"),
323        response_message: xml_value(&raw_xml, "RESPMSG"),
324        order_id: xml_value(&raw_xml, "ORDER_ID"),
325        more_messages: at(p, 15, 1) == "1",
326        id_message: at(p, 16, 3).to_string(),
327        raw_xml,
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    // Left-justified field, right-padded with spaces to `width` (alpha fields).
336    fn a(value: &str, width: usize) -> String {
337        let mut s = value.to_string();
338        s.push_str(&" ".repeat(width.saturating_sub(value.len())));
339        s
340    }
341
342    // Right-justified numeric field, left-padded with '0' to `width`.
343    fn n(value: &str, width: usize) -> String {
344        format!("{}{}", "0".repeat(width.saturating_sub(value.len())), value)
345    }
346
347    #[test]
348    fn payment_positive() {
349        let p = format!(
350            "{}0E00{}{}{}2111520{}{}{}{}",
351            a("12345678", 8),
352            n("4111111111", 19),
353            a("ICC", 3),
354            a("ABC123", 6),
355            "2",
356            a("ACQ", 11),
357            n("42", 6),
358            n("99", 6)
359        );
360        let r = parse_payment(&p);
361        assert_eq!(r.outcome, TransactionOutcome::Ok);
362        assert_eq!(r.result_code, "00");
363        assert_eq!(r.pan, n("4111111111", 19));
364        assert_eq!(r.transaction_type, "ICC");
365        assert_eq!(r.auth_code, "ABC123");
366        assert_eq!(r.host_date_time, "2111520");
367        assert_eq!(r.card_type, "2");
368        assert_eq!(r.acquirer_id, "ACQ");
369        assert_eq!(r.stan, "000042");
370        assert_eq!(r.online_id, "000099");
371        assert!(!r.currency.applied);
372    }
373
374    #[test]
375    fn payment_negative() {
376        let p = format!(
377            "{}0E01{}{}3{}{}{}",
378            a("12345678", 8),
379            a("CARTA RIFIUTATA", 24),
380            n("", 11), // reserved 37-47
381            a("AC2", 11),
382            n("7", 6),
383            n("3", 6)
384        );
385        let r = parse_payment(&p);
386        assert_eq!(r.outcome, TransactionOutcome::Ko);
387        assert_eq!(r.result_code, "01");
388        assert_eq!(r.error_description, "CARTA RIFIUTATA");
389        assert_eq!(r.card_type, "3");
390        assert_eq!(r.stan, "000007");
391    }
392
393    #[test]
394    fn payment_with_currency_exchange() {
395        let base = format!(
396            "{}0V00{}{}{}2111520{}{}{}{}",
397            a("12345678", 8),
398            n("4111111111", 19),
399            a("ICC", 3),
400            a("ABC123", 6),
401            "2",
402            a("ACQ", 11),
403            n("42", 6),
404            n("99", 6)
405        );
406        // actionCode(3) origAmount(8) flag(1) rate(8) ccy(3) amount(12) precision(1)
407        let p = format!(
408            "{base}000{}1{}USD{}2",
409            n("650", 8),
410            n("12345", 8),
411            n("650", 12)
412        );
413        let r = parse_payment(&p);
414        assert_eq!(r.outcome, TransactionOutcome::Ok);
415        assert!(r.currency.applied);
416        assert_eq!(r.currency.rate, "00012345");
417        assert_eq!(r.currency.currency_code, "USD");
418        assert_eq!(r.currency.amount, "000000000650");
419        assert_eq!(r.currency.precision, "2");
420    }
421
422    #[test]
423    fn status() {
424        let p = format!("{}0s{}0102251530{}V1.2.3", a("12345678", 8), n("", 10), "2");
425        let r = parse_status(&p);
426        assert_eq!(r.terminal_id, "12345678");
427        assert_eq!(r.date_time_raw, "0102251530");
428        assert_eq!(r.status, 2);
429        assert_eq!(r.software_release, "V1.2.3");
430    }
431
432    #[test]
433    fn totals() {
434        let p = format!("{}0T00{}{}", a("12345678", 8), n("123456", 16), n("", 6));
435        let r = parse_totals(&p);
436        assert_eq!(r.outcome, TransactionOutcome::Ok);
437        assert_eq!(r.pos_total, n("123456", 16));
438    }
439
440    #[test]
441    fn close_positive() {
442        let p = format!("{}0C00{}{}", a("12345678", 8), n("1000", 16), n("1000", 16));
443        let r = parse_close(&p);
444        assert_eq!(r.outcome, TransactionOutcome::Ok);
445        assert_eq!(r.pos_total, n("1000", 16));
446        assert_eq!(r.host_total, n("1000", 16));
447    }
448
449    #[test]
450    fn close_negative() {
451        let p = format!("{}0C01{}100", a("12345678", 8), a("SBILANCIO", 19));
452        let r = parse_close(&p);
453        assert_eq!(r.outcome, TransactionOutcome::Ko);
454        assert_eq!(r.error_description, "SBILANCIO");
455        assert_eq!(r.action_code, "100");
456    }
457
458    #[test]
459    fn pre_auth_positive() {
460        let p = format!(
461            "{}0e00{}{}{}{}{}000{}",
462            a("12345678", 8),
463            n("4111111111", 19),
464            a("CLI", 3),
465            a("AUTH01", 6),
466            n("50000", 8),
467            n("123", 9),
468            "2111520"
469        );
470        let r = parse_pre_auth(&p);
471        assert_eq!(r.outcome, TransactionOutcome::Ok);
472        assert_eq!(r.transaction_type, "CLI");
473        assert_eq!(r.auth_code, "AUTH01");
474        assert_eq!(r.pre_authorized_amount, "00050000");
475        assert_eq!(r.pre_auth_code, "000000123");
476        assert_eq!(r.host_date_time, "2111520");
477    }
478
479    // Regression: on an approved pre-auth the amount field occupies positions 41-48, so
480    // its last digit sits exactly where card_type would be read. An amount ending in
481    // 1/2/3 must NOT be surfaced as debit/credit/other.
482    #[test]
483    fn pre_auth_positive_does_not_leak_amount_digit_as_card_type() {
484        let p = format!(
485            "{}0e00{}{}{}{}{}000{}",
486            a("12345678", 8),
487            n("4111111111", 19),
488            a("CLI", 3),
489            a("AUTH01", 6),
490            n("50001", 8),
491            n("123", 9),
492            "2111520"
493        );
494        let r = parse_pre_auth(&p);
495        assert_eq!(r.outcome, TransactionOutcome::Ok);
496        assert_eq!(r.pre_authorized_amount, "00050001"); // ends in '1'
497        assert_eq!(r.card_type, ""); // must stay empty, not "1"
498    }
499
500    #[test]
501    fn pre_auth_negative() {
502        // KO layout: result@11 error@13(24) action@37(3) reserved(8) cardType@48
503        // reserved(23) acquirer@72(11) stan@83(6) onlineId@89(6).
504        let p = format!(
505            "{}0e01{}100{}3{}{}{}{}",
506            a("12345678", 8),
507            a("NEGATO", 24),
508            n("", 8),
509            n("", 23),
510            a("ACQ", 11),
511            n("55", 6),
512            n("66", 6)
513        );
514        let r = parse_pre_auth(&p);
515        assert_eq!(r.outcome, TransactionOutcome::Ko);
516        assert_eq!(r.error_description, "NEGATO");
517        assert_eq!(r.action_code, "100");
518        assert_eq!(r.card_type, "3"); // only read on the KO layout
519        assert_eq!(r.acquirer_id, "ACQ");
520        assert_eq!(r.stan, "000055");
521        assert_eq!(r.online_id, "000066");
522        assert_eq!(r.pan, ""); // no PAN on a declined pre-auth
523    }
524
525    #[test]
526    fn vas() {
527        let xml = "<ecrres><p k=\"RESPID\">0</p><p k=\"RESPMSG\">OK-APPROVED</p>\
528                   <p k=\"ORDER_ID\">ABC123</p></ecrres>";
529        // header(10) reserved(4) concatFlag(1) idMessage(3) filler-to-pos27(8) xml
530        let p = format!("{}0K{}0001{}{}", a("12345678", 8), n("", 4), n("", 8), xml);
531        let r = parse_vas(&p);
532        assert!(!r.more_messages);
533        assert_eq!(r.id_message, "001");
534        assert_eq!(r.response_id, "0");
535        assert_eq!(r.response_message, "OK-APPROVED");
536        assert_eq!(r.order_id, "ABC123");
537        assert_eq!(r.raw_xml, xml);
538    }
539
540    #[test]
541    fn defensive_on_short_or_empty_payload() {
542        let r = parse_payment("");
543        assert_eq!(r.outcome, TransactionOutcome::Unknown);
544        assert_eq!(r.result_code, "");
545        assert_eq!(r.pan, "");
546
547        let s = parse_status("123"); // truncated, must not panic
548        assert_eq!(s.status, -1);
549    }
550
551    #[test]
552    fn outcome_mapping() {
553        assert_eq!(outcome_from_code("00"), TransactionOutcome::Ok);
554        assert_eq!(outcome_from_code("01"), TransactionOutcome::Ko);
555        assert_eq!(outcome_from_code("05"), TransactionOutcome::CardNotPresent);
556        assert_eq!(outcome_from_code("09"), TransactionOutcome::UnknownTag);
557        assert_eq!(outcome_from_code("zz"), TransactionOutcome::Unknown);
558    }
559}