Skip to main content

ecr17_protocol/
client.rs

1//! [`Ecr17Client`] — the high-level async API: builds requests, drives the
2//! [`Ecr17Session`], parses/maps responses to the typed [`crate::types`] results, and
3//! applies the money-safe auto-reconnect policy. Port of the reference C++ `HybridEcr17Client`.
4//!
5//! 💰 On a mid-command drop the socket is reconnected (when `auto_reconnect` is on), but a
6//! **financial** command is never replayed (double-charge) — only read-only/idempotent ops
7//! are retried; a lost financial response is recovered via [`Ecr17Client::send_last_result`]
8//! (`G`). The decision is [`crate::retry::should_retry_after_reconnect`].
9
10use std::sync::{Arc, Mutex};
11
12use crate::codec::DecodedPacket;
13use crate::error::Result;
14use crate::response;
15use crate::retry::should_retry_after_reconnect;
16use crate::session::{Ecr17Session, SessionConfig};
17use crate::transport::Transport;
18use crate::types::{
19    CardType, CardVerificationRequest, CardVerificationResult, CloseSessionResult, ConnectionState,
20    CurrencyExchange, Ecr17Config, IncrementalAuthRequest, PaymentRequest, PaymentResult,
21    PosStatusResponse, PreAuthClosureRequest, PreAuthRequest, PreAuthResult, ProgressEvent,
22    ReceiptLine, ReversalRequest, ReversalResult, TokenizationRequest, TotalsResult,
23    TransactionEntryMode, VasResult,
24};
25
26// The callback is stored behind an inner `Arc` so a caller can be cloned OUT of the mutex
27// and invoked with the lock released — never hold the lock across a user callback (that
28// would deadlock if the callback re-enters, e.g. re-registers itself).
29type SharedCb<T> = Arc<Mutex<Option<Arc<dyn Fn(T) + Send + Sync + 'static>>>>;
30
31/// High-level ECR17 client over a [`Transport`] `T`.
32pub struct Ecr17Client<T: Transport> {
33    config: Ecr17Config,
34    session: Ecr17Session<T>,
35    on_progress: SharedCb<ProgressEvent>,
36    on_receipt_line: SharedCb<ReceiptLine>,
37    on_connection_state_change: SharedCb<ConnectionState>,
38}
39
40impl<T: Transport> std::fmt::Debug for Ecr17Client<T> {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Ecr17Client")
43            .field("config", &self.config)
44            .finish_non_exhaustive()
45    }
46}
47
48impl<T: Transport> Ecr17Client<T> {
49    /// Creates a client over `transport` with `config`. The transport must already target
50    /// the configured host/port (it is (re)connected on demand).
51    pub fn new(transport: T, config: Ecr17Config) -> Self {
52        let mut session = Ecr17Session::new(transport, session_config(&config));
53
54        let on_progress: SharedCb<ProgressEvent> = Arc::new(Mutex::new(None));
55        let on_receipt_line: SharedCb<ReceiptLine> = Arc::new(Mutex::new(None));
56
57        let p = Arc::clone(&on_progress);
58        session.set_on_progress(move |message| {
59            let cb = p.lock().unwrap().clone(); // clone the Arc, then release the lock
60            if let Some(cb) = cb {
61                cb(ProgressEvent { message });
62            }
63        });
64        let r = Arc::clone(&on_receipt_line);
65        session.set_on_receipt_line(move |text| {
66            let cb = r.lock().unwrap().clone();
67            if let Some(cb) = cb {
68                cb(ReceiptLine { text });
69            }
70        });
71
72        Self {
73            config,
74            session,
75            on_progress,
76            on_receipt_line,
77            on_connection_state_change: Arc::new(Mutex::new(None)),
78        }
79    }
80
81    /// The active configuration.
82    #[must_use]
83    pub fn configuration(&self) -> &Ecr17Config {
84        &self.config
85    }
86
87    /// Registers the progress-update listener (`SOH` frames during a procedure).
88    pub fn set_on_progress(&self, cb: impl Fn(ProgressEvent) + Send + Sync + 'static) {
89        *self.on_progress.lock().unwrap() = Some(Arc::new(cb));
90    }
91
92    /// Registers the receipt-line listener (`S` messages when ECR printing is on).
93    pub fn set_on_receipt_line(&self, cb: impl Fn(ReceiptLine) + Send + Sync + 'static) {
94        *self.on_receipt_line.lock().unwrap() = Some(Arc::new(cb));
95    }
96
97    /// Registers the connection-state listener.
98    pub fn set_on_connection_state_change(
99        &self,
100        cb: impl Fn(ConnectionState) + Send + Sync + 'static,
101    ) {
102        *self.on_connection_state_change.lock().unwrap() = Some(Arc::new(cb));
103    }
104
105    /// Borrows the underlying session's transport (read-only).
106    pub fn transport(&self) -> &T {
107        self.session.transport()
108    }
109
110    /// Opens the connection (emits `Connecting` → `Connected`/`Disconnected`).
111    pub async fn connect(&mut self) -> Result<()> {
112        self.ensure_connected().await
113    }
114
115    /// Closes the connection (emits `Disconnected`).
116    pub async fn disconnect(&mut self) {
117        self.session.disconnect().await;
118        self.emit_state(ConnectionState::Disconnected);
119    }
120
121    /// Whether the transport currently believes it is connected.
122    #[must_use]
123    pub fn is_connected(&self) -> bool {
124        self.session.is_connected()
125    }
126
127    // --- Commands ---
128
129    /// Status (`s`).
130    pub async fn status(&mut self) -> Result<PosStatusResponse> {
131        self.ensure_connected().await?;
132        let payload = crate::protocol::build_status(&self.config.terminal_id)?;
133        let pkt = self.run_transaction(&payload, None, true).await?;
134        Ok(map_status(&response::parse_status(&as_str(&pkt))))
135    }
136
137    /// Payment (`P`).
138    pub async fn pay(&mut self, request: &PaymentRequest) -> Result<PaymentResult> {
139        self.ensure_connected().await?;
140        let payload = self.payment_payload('P', request)?;
141        let pkt = self
142            .run_transaction(&payload, request.tokenization.as_ref(), false)
143            .await?;
144        Ok(map_payment(&response::parse_payment(&as_str(&pkt))))
145    }
146
147    /// Extended payment (`X`).
148    pub async fn pay_extended(&mut self, request: &PaymentRequest) -> Result<PaymentResult> {
149        self.ensure_connected().await?;
150        let payload = self.payment_payload('X', request)?;
151        let pkt = self
152            .run_transaction(&payload, request.tokenization.as_ref(), false)
153            .await?;
154        Ok(map_payment(&response::parse_payment(&as_str(&pkt))))
155    }
156
157    /// Reversal (`S`).
158    pub async fn reverse(&mut self, request: &ReversalRequest) -> Result<ReversalResult> {
159        self.ensure_connected().await?;
160        let payload = crate::protocol::build_reversal(
161            &self.config.terminal_id,
162            self.cash_register_id_or(request.cash_register_id.as_deref()),
163            request.stan.as_deref().unwrap_or("000000"),
164        )?;
165        let pkt = self.run_transaction(&payload, None, false).await?;
166        Ok(map_reversal(&response::parse_payment(&as_str(&pkt))))
167    }
168
169    /// Pre-auth (`p`).
170    pub async fn pre_auth(&mut self, request: &PreAuthRequest) -> Result<PreAuthResult> {
171        self.ensure_connected().await?;
172        let payload = crate::protocol::build_pre_auth(
173            &self.config.terminal_id,
174            self.cash_register_id_or(request.cash_register_id.as_deref()),
175            request.amount_cents,
176            request.payment_type.unwrap_or_default().as_digit(),
177            request.card_already_present.unwrap_or(false),
178            request.tokenization.is_some(),
179            request.receipt_text.as_deref().unwrap_or(""),
180        )?;
181        let pkt = self
182            .run_transaction(&payload, request.tokenization.as_ref(), false)
183            .await?;
184        Ok(map_pre_auth(&response::parse_pre_auth(&as_str(&pkt))))
185    }
186
187    /// Incremental pre-auth (`i`).
188    pub async fn incremental_auth(
189        &mut self,
190        request: &IncrementalAuthRequest,
191    ) -> Result<PreAuthResult> {
192        self.ensure_connected().await?;
193        let payload = crate::protocol::build_incremental(
194            &self.config.terminal_id,
195            self.cash_register_id_or(request.cash_register_id.as_deref()),
196            request.amount_cents,
197            &request.original_pre_auth_code,
198            false,
199            request.receipt_text.as_deref().unwrap_or(""),
200        )?;
201        let pkt = self.run_transaction(&payload, None, false).await?;
202        Ok(map_pre_auth(&response::parse_pre_auth(&as_str(&pkt))))
203    }
204
205    /// Pre-auth closure (`c`).
206    pub async fn pre_auth_closure(
207        &mut self,
208        request: &PreAuthClosureRequest,
209    ) -> Result<PaymentResult> {
210        self.ensure_connected().await?;
211        let payload = crate::protocol::build_pre_auth_closure(
212            &self.config.terminal_id,
213            self.cash_register_id_or(request.cash_register_id.as_deref()),
214            request.amount_cents,
215            &request.original_pre_auth_code,
216            false,
217            request.receipt_text.as_deref().unwrap_or(""),
218        )?;
219        let pkt = self.run_transaction(&payload, None, false).await?;
220        Ok(map_payment(&response::parse_payment(&as_str(&pkt))))
221    }
222
223    /// Card verification (`H`).
224    pub async fn verify_card(
225        &mut self,
226        request: &CardVerificationRequest,
227    ) -> Result<CardVerificationResult> {
228        self.ensure_connected().await?;
229        let payload = crate::protocol::build_card_verification(
230            &self.config.terminal_id,
231            self.cash_register_id_or(request.cash_register_id.as_deref()),
232            request.payment_type.unwrap_or_default().as_digit(),
233            request.tokenization.is_some(),
234        )?;
235        let pkt = self
236            .run_transaction(&payload, request.tokenization.as_ref(), false)
237            .await?;
238        Ok(map_card_verify(&response::parse_payment(&as_str(&pkt))))
239    }
240
241    /// Close session (`C`).
242    pub async fn close_session(&mut self) -> Result<CloseSessionResult> {
243        self.ensure_connected().await?;
244        let payload = crate::protocol::build_close_session(
245            &self.config.terminal_id,
246            &self.config.cash_register_id,
247            false,
248        )?;
249        let pkt = self.run_transaction(&payload, None, false).await?;
250        Ok(map_close(&response::parse_close(&as_str(&pkt))))
251    }
252
253    /// Totals (`T`).
254    pub async fn totals(&mut self) -> Result<TotalsResult> {
255        self.ensure_connected().await?;
256        let payload = crate::protocol::build_totals(
257            &self.config.terminal_id,
258            &self.config.cash_register_id,
259            false,
260        )?;
261        let pkt = self.run_transaction(&payload, None, true).await?;
262        Ok(map_totals(&response::parse_totals(&as_str(&pkt))))
263    }
264
265    /// Send last result (`G`) — recovers a lost financial response without re-charging.
266    pub async fn send_last_result(&mut self) -> Result<PaymentResult> {
267        self.ensure_connected().await?;
268        let payload = crate::protocol::build_send_last_result(
269            &self.config.terminal_id,
270            &self.config.cash_register_id,
271            false,
272        )?;
273        let pkt = self.run_transaction(&payload, None, true).await?;
274        Ok(map_payment(&response::parse_payment(&as_str(&pkt))))
275    }
276
277    /// Enable/disable ECR printing (`E`).
278    pub async fn enable_ecr_printing(&mut self, enabled: bool) -> Result<()> {
279        self.ensure_connected().await?;
280        let payload = crate::protocol::build_enable_ecr_print(&self.config.terminal_id, enabled)?;
281        self.run_ack_only(&payload, true).await
282    }
283
284    /// Reprint the last ticket (`R`).
285    pub async fn reprint(&mut self, to_ecr: bool) -> Result<()> {
286        self.ensure_connected().await?;
287        let payload = crate::protocol::build_reprint(&self.config.terminal_id, to_ecr, '0')?;
288        self.run_ack_only(&payload, false).await
289    }
290
291    /// VAS (`K`).
292    ///
293    /// Reads a single VAS response message (as the reference does). A multi-part response
294    /// (`more_messages` set) is not concatenated here — that is a documented limitation to
295    /// validate against a real terminal before implementing.
296    pub async fn vas(&mut self, xml_request: &str) -> Result<VasResult> {
297        self.ensure_connected().await?;
298        let payload = crate::protocol::build_vas(
299            &self.config.terminal_id,
300            &self.config.cash_register_id,
301            xml_request,
302        )?;
303        let pkt = self.run_transaction(&payload, None, false).await?;
304        Ok(map_vas(&response::parse_vas(&as_str(&pkt))))
305    }
306
307    // --- internals ---
308
309    fn cash_register_id_or<'a>(&'a self, override_id: Option<&'a str>) -> &'a str {
310        override_id.unwrap_or(&self.config.cash_register_id)
311    }
312
313    fn payment_payload(&self, code: char, request: &PaymentRequest) -> Result<String> {
314        let builder = if code == 'X' {
315            crate::protocol::build_extended_payment
316        } else {
317            crate::protocol::build_payment
318        };
319        builder(
320            &self.config.terminal_id,
321            self.cash_register_id_or(request.cash_register_id.as_deref()),
322            request.amount_cents,
323            request.payment_type.unwrap_or_default().as_digit(),
324            request.card_already_present.unwrap_or(false),
325            request.tokenization.is_some(),
326            request.receipt_text.as_deref().unwrap_or(""),
327        )
328    }
329
330    fn emit_state(&self, state: ConnectionState) {
331        let cb = self.on_connection_state_change.lock().unwrap().clone();
332        if let Some(cb) = cb {
333            cb(state);
334        }
335    }
336
337    /// Ensures an open connection. `is_connected()` performs the transport's (proactive,
338    /// non-destructive) liveness probe, so a peer-closed/half-open socket — common because
339    /// Nexi terminals close TCP between transactions — is detected here, before a command
340    /// is sent on a stale socket.
341    async fn ensure_connected(&mut self) -> Result<()> {
342        if self.session.is_connected() {
343            return Ok(());
344        }
345        self.emit_state(ConnectionState::Connecting);
346        match self.session.connect().await {
347            Ok(()) => {
348                self.emit_state(ConnectionState::Connected);
349                Ok(())
350            }
351            Err(e) => {
352                self.emit_state(ConnectionState::Disconnected);
353                Err(e)
354            }
355        }
356    }
357
358    async fn do_exchange(
359        &mut self,
360        main_payload: &str,
361        tokenization: Option<&TokenizationRequest>,
362    ) -> Result<DecodedPacket> {
363        if let Some(tok) = tokenization {
364            let tag = crate::protocol::format_tokenization_tag(
365                tok.service.is_recurring(),
366                &tok.contract_code,
367            )?;
368            let additional = crate::protocol::build_additional_tags(
369                &self.config.terminal_id,
370                &tag,
371                "62",
372                "DF8D01",
373            )?;
374            self.session
375                .exchange_with_additional_data(main_payload, &additional)
376                .await
377        } else {
378            self.session.exchange(main_payload).await
379        }
380    }
381
382    /// Runs an exchange with the money-safe auto-reconnect policy: on a mid-command error,
383    /// reconnect (if `auto_reconnect` and the transport dropped), then replay ONLY when
384    /// [`should_retry_after_reconnect`] allows it — never a financial op.
385    async fn run_transaction(
386        &mut self,
387        main_payload: &str,
388        tokenization: Option<&TokenizationRequest>,
389        safe_to_retry: bool,
390    ) -> Result<DecodedPacket> {
391        match self.do_exchange(main_payload, tokenization).await {
392            Ok(pkt) => Ok(pkt),
393            Err(original) => {
394                if self.recover_after_error(safe_to_retry).await {
395                    self.do_exchange(main_payload, tokenization).await
396                } else {
397                    Err(original)
398                }
399            }
400        }
401    }
402
403    /// Like [`run_transaction`](Self::run_transaction) for commands whose only reply is the
404    /// physical ACK (`E`/`R`).
405    async fn run_ack_only(&mut self, payload: &str, safe_to_retry: bool) -> Result<()> {
406        match self.session.send_ack_only(payload).await {
407            Ok(()) => Ok(()),
408            Err(original) => {
409                if self.recover_after_error(safe_to_retry).await {
410                    self.session.send_ack_only(payload).await
411                } else {
412                    Err(original)
413                }
414            }
415        }
416    }
417
418    /// Reconnects after a mid-command error (when `auto_reconnect` and the transport
419    /// dropped) and returns whether the caller may **replay** the command. Returns `false`
420    /// for a financial op (never replay → surface the original error) or a failed reconnect.
421    async fn recover_after_error(&mut self, safe_to_retry: bool) -> bool {
422        let auto_reconnect = self.config.auto_reconnect.unwrap_or(false);
423        let dropped = !self.session.is_connected();
424        if auto_reconnect && dropped && self.ensure_connected().await.is_err() {
425            return false; // reconnect failed → the caller surfaces the original error
426        }
427        should_retry_after_reconnect(auto_reconnect, dropped, safe_to_retry)
428    }
429}
430
431fn session_config(config: &Ecr17Config) -> SessionConfig {
432    SessionConfig {
433        lrc_mode: config.lrc_mode.unwrap_or_default(),
434        ack_timeout_ms: u64::from(config.ack_timeout_ms.unwrap_or(2000)),
435        response_timeout_ms: u64::from(config.response_timeout_ms.unwrap_or(60_000)),
436        retry_count: config.retry_count.unwrap_or(3),
437        retry_delay_ms: u64::from(config.retry_delay_ms.unwrap_or(200)),
438        receipt_drain_ms: u64::from(config.receipt_drain_ms.unwrap_or(0)),
439    }
440}
441
442fn as_str(pkt: &DecodedPacket) -> String {
443    String::from_utf8_lossy(&pkt.payload).into_owned()
444}
445
446// --- raw response -> typed result mappers (port of the C++ map* helpers) ---
447
448fn opt_str(s: &str) -> Option<String> {
449    if s.is_empty() {
450        None
451    } else {
452        Some(s.to_string())
453    }
454}
455
456fn opt_i64(s: &str) -> Option<i64> {
457    s.trim().parse::<i64>().ok()
458}
459
460fn opt_f64(s: &str) -> Option<f64> {
461    s.trim().parse::<f64>().ok()
462}
463
464fn opt_i32(s: &str) -> Option<i32> {
465    s.trim().parse::<i32>().ok()
466}
467
468fn map_card_type(raw: &str) -> Option<CardType> {
469    match raw {
470        "1" => Some(CardType::Debit),
471        "2" => Some(CardType::Credit),
472        "3" => Some(CardType::Other),
473        _ => None,
474    }
475}
476
477fn map_entry_mode(raw: &str) -> Option<TransactionEntryMode> {
478    match raw {
479        "ICC" => Some(TransactionEntryMode::Icc),
480        "MAG" => Some(TransactionEntryMode::Mag),
481        "MAN" => Some(TransactionEntryMode::Manual),
482        "CLM" => Some(TransactionEntryMode::ClessMag),
483        "CLI" => Some(TransactionEntryMode::ClessIcc),
484        _ => None,
485    }
486}
487
488fn map_payment(p: &response::PaymentResponse) -> PaymentResult {
489    PaymentResult {
490        outcome: p.outcome,
491        result_code: p.result_code.clone(),
492        pan: opt_str(&p.pan),
493        entry_mode: map_entry_mode(&p.transaction_type),
494        auth_code: opt_str(&p.auth_code),
495        host_date_time: opt_str(&p.host_date_time),
496        card_type: map_card_type(&p.card_type),
497        acquirer_id: opt_str(&p.acquirer_id),
498        stan: opt_str(&p.stan),
499        online_id: opt_str(&p.online_id),
500        error_description: opt_str(&p.error_description),
501        // DCC values are exposed as the terminal's RAW integers (rate/amount unscaled),
502        // with `precision` giving the number of implied decimals — matching the reference.
503        // Consumers scale with `precision` (e.g. rate 12345 @ precision 4 = 1.2345); the
504        // library does not scale so no rounding is imposed on money values.
505        currency_exchange: p.currency.applied.then(|| CurrencyExchange {
506            applied: true,
507            rate: opt_f64(&p.currency.rate),
508            currency_code: opt_str(&p.currency.currency_code),
509            amount_cents: opt_i64(&p.currency.amount),
510            precision: opt_i32(&p.currency.precision),
511        }),
512    }
513}
514
515fn map_reversal(p: &response::PaymentResponse) -> ReversalResult {
516    ReversalResult {
517        outcome: p.outcome,
518        result_code: p.result_code.clone(),
519        pan: opt_str(&p.pan),
520        entry_mode: map_entry_mode(&p.transaction_type),
521        host_date_time: opt_str(&p.host_date_time),
522        card_type: map_card_type(&p.card_type),
523        acquirer_id: opt_str(&p.acquirer_id),
524        stan: opt_str(&p.stan),
525        online_id: opt_str(&p.online_id),
526        action_code: None,
527        error_description: opt_str(&p.error_description),
528    }
529}
530
531fn map_card_verify(p: &response::PaymentResponse) -> CardVerificationResult {
532    CardVerificationResult {
533        outcome: p.outcome,
534        result_code: p.result_code.clone(),
535        pan: opt_str(&p.pan),
536        entry_mode: map_entry_mode(&p.transaction_type),
537        auth_code: opt_str(&p.auth_code),
538        host_date_time: opt_str(&p.host_date_time),
539        card_type: map_card_type(&p.card_type),
540        acquirer_id: opt_str(&p.acquirer_id),
541        stan: opt_str(&p.stan),
542        online_id: opt_str(&p.online_id),
543        action_code: None,
544        error_description: opt_str(&p.error_description),
545    }
546}
547
548fn map_pre_auth(p: &response::PreAuthResponse) -> PreAuthResult {
549    PreAuthResult {
550        outcome: p.outcome,
551        result_code: p.result_code.clone(),
552        pan: opt_str(&p.pan),
553        entry_mode: map_entry_mode(&p.transaction_type),
554        auth_code: opt_str(&p.auth_code),
555        pre_authorized_amount_cents: opt_i64(&p.pre_authorized_amount),
556        pre_auth_code: opt_str(&p.pre_auth_code),
557        action_code: opt_str(&p.action_code),
558        host_date_time: opt_str(&p.host_date_time),
559        card_type: map_card_type(&p.card_type),
560        acquirer_id: opt_str(&p.acquirer_id),
561        stan: opt_str(&p.stan),
562        online_id: opt_str(&p.online_id),
563        error_description: opt_str(&p.error_description),
564    }
565}
566
567fn map_status(s: &response::StatusResponse) -> PosStatusResponse {
568    PosStatusResponse {
569        terminal_id: s.terminal_id.clone(),
570        terminal_date_time: raw_datetime_to_iso(&s.date_time_raw),
571        status: s.status,
572        software_release: s.software_release.clone(),
573    }
574}
575
576fn map_totals(t: &response::TotalsResponse) -> TotalsResult {
577    TotalsResult {
578        outcome: t.outcome,
579        result_code: t.result_code.clone(),
580        pos_total_cents: opt_i64(&t.pos_total).unwrap_or(0),
581    }
582}
583
584fn map_close(c: &response::CloseResponse) -> CloseSessionResult {
585    CloseSessionResult {
586        outcome: c.outcome,
587        result_code: c.result_code.clone(),
588        pos_total_cents: opt_i64(&c.pos_total),
589        host_total_cents: opt_i64(&c.host_total),
590        action_code: opt_str(&c.action_code),
591        error_description: opt_str(&c.error_description),
592    }
593}
594
595fn map_vas(v: &response::VasResponse) -> VasResult {
596    VasResult {
597        response_id: v.response_id.clone(),
598        response_message: v.response_message.clone(),
599        order_id: opt_str(&v.order_id),
600        raw_xml: v.raw_xml.clone(),
601    }
602}
603
604/// Converts the terminal's raw `DDMMYYhhmm` to an ISO 8601 string
605/// (`20YY-MM-DDThh:mm:00`); empty string if the input is too short.
606fn raw_datetime_to_iso(raw: &str) -> String {
607    if raw.len() < 10 {
608        return String::new();
609    }
610    let (dd, mm, yy, hh, min) = (&raw[0..2], &raw[2..4], &raw[4..6], &raw[6..8], &raw[8..10]);
611    format!("20{yy}-{mm}-{dd}T{hh}:{min}:00")
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::codec::{PacketCodec, ACK, EOT, SOH};
618    use crate::error::Ecr17Error;
619    use crate::lrc::LrcMode;
620    use crate::transport::FakeTransport;
621    use crate::types::PaymentCardType;
622
623    fn config() -> Ecr17Config {
624        Ecr17Config {
625            host: "127.0.0.1".into(),
626            port: None,
627            terminal_id: "12345678".into(),
628            cash_register_id: "00000001".into(),
629            lrc_mode: Some(LrcMode::Std),
630            keep_alive: None,
631            auto_reconnect: Some(true),
632            connection_timeout_ms: None,
633            response_timeout_ms: Some(40),
634            ack_timeout_ms: Some(40),
635            receipt_drain_ms: None,
636            retry_count: Some(2),
637            retry_delay_ms: Some(1),
638            debug: None,
639        }
640    }
641
642    // A minimal spec-shaped payment result ('E' code, result "00" = OK) with a PAN.
643    fn ok_payment_result() -> Vec<u8> {
644        let c = PacketCodec::new(LrcMode::Std);
645        // header(8 id +'0'+'E') + result "00" + pan(19) + txType"ICC"(3) + auth"AUTH01"(6)
646        // + hostDate(7) + cardType"2" + acquirer"ACQ"(11) + stan(6) + online(6)
647        let mut payload = String::from("123456780E00");
648        payload.push_str(&"0".repeat(19)); // pan
649        payload.push_str("ICC");
650        payload.push_str("AUTH01");
651        payload.push_str("2111520");
652        payload.push('2');
653        payload.push_str("ACQ        ");
654        payload.push_str("000042");
655        payload.push_str("000099");
656        c.encode_application(payload.as_bytes())
657    }
658
659    fn ack_then(result: Vec<u8>) -> Vec<u8> {
660        let mut r = PacketCodec::new(LrcMode::Std).encode_control(ACK);
661        r.extend(result);
662        r
663    }
664
665    #[tokio::test]
666    async fn pay_maps_an_approved_result() {
667        let mut t = FakeTransport::new();
668        t.enqueue_response(ack_then(ok_payment_result()));
669        let mut client = Ecr17Client::new(t, config());
670
671        let req = PaymentRequest {
672            amount_cents: 650,
673            cash_register_id: None,
674            payment_type: Some(PaymentCardType::Credit),
675            card_already_present: None,
676            receipt_text: None,
677            tokenization: None,
678        };
679        let result = client.pay(&req).await.unwrap();
680        assert_eq!(result.outcome, crate::types::TransactionOutcome::Ok);
681        assert_eq!(result.result_code, "00");
682        assert_eq!(result.entry_mode, Some(TransactionEntryMode::Icc));
683        assert_eq!(result.card_type, Some(CardType::Credit));
684        assert_eq!(result.auth_code.as_deref(), Some("AUTH01"));
685        assert_eq!(result.stan.as_deref(), Some("000042"));
686    }
687
688    #[tokio::test]
689    async fn client_forwards_progress_to_listener() {
690        let c = PacketCodec::new(LrcMode::Std);
691        let mut response = c.encode_control(ACK);
692        let mut progress_frame = vec![SOH];
693        progress_frame.extend_from_slice(b"ATTENDERE PREGO     ");
694        progress_frame.push(EOT);
695        response.extend(progress_frame);
696        response.extend(ok_payment_result());
697        let mut t = FakeTransport::new();
698        t.enqueue_response(response);
699        let mut client = Ecr17Client::new(t, config());
700
701        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
702        let sink = Arc::clone(&seen);
703        client.set_on_progress(move |e| sink.lock().unwrap().push(e.message));
704
705        let req = PaymentRequest {
706            amount_cents: 650,
707            cash_register_id: None,
708            payment_type: None,
709            card_already_present: None,
710            receipt_text: None,
711            tokenization: None,
712        };
713        client.pay(&req).await.unwrap();
714        assert_eq!(
715            *seen.lock().unwrap(),
716            vec!["ATTENDERE PREGO     ".to_string()]
717        );
718    }
719
720    #[tokio::test]
721    async fn status_maps_iso_datetime() {
722        let c = PacketCodec::new(LrcMode::Std);
723        // termId(8) '0' 's' reserved(10) dateTime"0102251530" status"2" sw"V1.2.3"
724        let mut payload = String::from("123456780s");
725        payload.push_str(&"0".repeat(10));
726        payload.push_str("0102251530");
727        payload.push('2');
728        payload.push_str("V1.2.3");
729        let mut t = FakeTransport::new();
730        t.enqueue_response(ack_then(c.encode_application(payload.as_bytes())));
731        let mut client = Ecr17Client::new(t, config());
732
733        let s = client.status().await.unwrap();
734        assert_eq!(s.terminal_id, "12345678");
735        assert_eq!(s.status, 2);
736        assert_eq!(s.terminal_date_time, "2025-02-01T15:30:00");
737        assert_eq!(s.software_release, "V1.2.3");
738    }
739
740    // 💰 A financial command that drops mid-exchange is NOT replayed even with
741    // auto_reconnect on; the error surfaces (recover via send_last_result).
742    #[tokio::test]
743    async fn financial_command_not_replayed_on_drop() {
744        let mut t = FakeTransport::new();
745        t.disconnect_on_next_request();
746        let mut client = Ecr17Client::new(t, config()); // auto_reconnect = true
747
748        let req = PaymentRequest {
749            amount_cents: 1000,
750            cash_register_id: None,
751            payment_type: None,
752            card_already_present: None,
753            receipt_text: None,
754            tokenization: None,
755        };
756        let err = client.pay(&req).await.unwrap_err();
757        assert!(matches!(err, Ecr17Error::Disconnected));
758        // Exactly one application request was sent — never a blind financial replay.
759        assert_eq!(client.transport().application_request_count(), 1);
760    }
761
762    // A safe/idempotent command (status) IS retried after an auto-reconnect.
763    #[tokio::test]
764    async fn safe_command_retried_after_reconnect() {
765        let c = PacketCodec::new(LrcMode::Std);
766        let mut t = FakeTransport::new();
767        t.disconnect_on_next_request(); // first status attempt drops
768                                        // status payload the second attempt will get an ACK + status result
769        let mut payload = String::from("123456780s");
770        payload.push_str(&"0".repeat(10));
771        payload.push_str("0102251530");
772        payload.push('2');
773        payload.push_str("V1");
774        t.enqueue_response(ack_then(c.encode_application(payload.as_bytes())));
775        let mut client = Ecr17Client::new(t, config());
776
777        let s = client.status().await.unwrap();
778        assert_eq!(s.status, 2);
779        // initial (dropped) + retry after reconnect = 2 application requests.
780        assert_eq!(client.transport().application_request_count(), 2);
781    }
782
783    #[test]
784    fn iso_datetime_conversion() {
785        assert_eq!(raw_datetime_to_iso("0102251530"), "2025-02-01T15:30:00");
786        assert_eq!(raw_datetime_to_iso("short"), "");
787    }
788}