Skip to main content

emv_3ds/
transaction.rs

1use crate::error::{Error, Result};
2use crate::message::{
3    AuthenticationRequest, AuthenticationResponse, ChallengeRequest, ChallengeResponse,
4    ErrorMessage, ResultsRequest,
5};
6use crate::types::{Eci, TransStatus};
7
8/// The lifecycle of a single 3DS transaction as seen by the 3DS Server.
9///
10/// Transitions are driven by sending an AReq and receiving subsequent messages.
11/// Terminal states are `Authenticated`, `NotAuthenticated`, and `Failed`.
12///
13/// ```text
14///  Created
15///    │  send_areq()
16///    ▼
17///  AwaitingARes
18///    │  receive_ares()
19///    ├─(Y/A)──────────────────────► Authenticated
20///    ├─(N/U/R)────────────────────► NotAuthenticated
21///    ├─(C)
22///    │    │
23///    │    ▼
24///    │  AwaitingCRes
25///    │    │  receive_cres()
26///    │    ├─(Y/A)────────────────► Authenticated
27///    │    └─(N/U)────────────────► NotAuthenticated
28///    └─(D)
29///         │
30///         ▼
31///       AwaitingRReq
32///         │  receive_rreq()
33///         ├─(Y/A)──────────────────► Authenticated
34///         └─(N/U/R)────────────────► NotAuthenticated
35///
36///  Any state + receive_error() ──► Failed
37/// ```
38#[derive(Debug, Clone)]
39pub enum TransactionState {
40    /// AReq has been constructed and is ready to send.
41    Created { areq: Box<AuthenticationRequest> },
42    /// AReq sent; waiting for the ARes from the DS.
43    AwaitingARes { three_ds_server_trans_id: String },
44    /// ARes received with transStatus=C; challenge must be presented.
45    AwaitingCRes {
46        three_ds_server_trans_id: String,
47        acs_trans_id: String,
48        /// ACS URL to redirect/embed (browser channel), or None for app/decoupled.
49        acs_url: Option<String>,
50    },
51    /// ARes received with transStatus=D; waiting for the ACS to send an RReq
52    /// after completing decoupled out-of-band authentication.
53    AwaitingRReq {
54        three_ds_server_trans_id: String,
55        acs_trans_id: String,
56    },
57    /// Authentication completed successfully (frictionless Y/A or challenge Y).
58    Authenticated {
59        three_ds_server_trans_id: String,
60        acs_trans_id: String,
61        ds_trans_id: Option<String>,
62        eci: Option<Eci>,
63        /// CAVV / AAV value to include in the authorization request.
64        authentication_value: Option<String>,
65    },
66    /// Authentication was rejected or failed (N, U, R, or challenge N).
67    NotAuthenticated {
68        three_ds_server_trans_id: String,
69        trans_status: TransStatus,
70        reason_code: Option<String>,
71    },
72    /// A protocol error was received; the transaction cannot continue.
73    Failed { error: String },
74}
75
76impl TransactionState {
77    /// Create a new transaction from a constructed AReq.
78    pub fn new(areq: AuthenticationRequest) -> Self {
79        Self::Created {
80            areq: Box::new(areq),
81        }
82    }
83
84    /// Mark the AReq as sent and transition to awaiting the ARes.
85    pub fn areq_sent(self) -> Result<(Self, AuthenticationRequest)> {
86        match self {
87            Self::Created { areq } => {
88                let id = areq.three_ds_server_trans_id.clone();
89                let next = Self::AwaitingARes {
90                    three_ds_server_trans_id: id,
91                };
92                Ok((next, *areq))
93            }
94            other => Err(Error::InvalidTransition {
95                from: other.name().to_owned(),
96                to: "AwaitingARes".to_owned(),
97            }),
98        }
99    }
100
101    /// Process an incoming ARes and advance the state.
102    pub fn receive_ares(self, ares: AuthenticationResponse) -> Result<Self> {
103        let Self::AwaitingARes {
104            three_ds_server_trans_id,
105        } = self
106        else {
107            return Err(Error::InvalidTransition {
108                from: self.name().to_owned(),
109                to: "post-ARes".to_owned(),
110            });
111        };
112
113        if ares.three_ds_server_trans_id != three_ds_server_trans_id {
114            return Err(Error::InvalidField {
115                field: "threeDSServerTransID",
116                reason: "ARes trans ID does not match AReq".to_owned(),
117            });
118        }
119
120        let next = match ares.trans_status {
121            TransStatus::Success | TransStatus::Attempted => Self::Authenticated {
122                three_ds_server_trans_id,
123                acs_trans_id: ares.acs_trans_id,
124                ds_trans_id: Some(ares.ds_trans_id),
125                eci: ares.eci,
126                authentication_value: ares.authentication_value,
127            },
128            TransStatus::ChallengeRequired => Self::AwaitingCRes {
129                three_ds_server_trans_id,
130                acs_trans_id: ares.acs_trans_id,
131                acs_url: ares.acs_url,
132            },
133            TransStatus::DecoupledRequired => Self::AwaitingRReq {
134                three_ds_server_trans_id,
135                acs_trans_id: ares.acs_trans_id,
136            },
137            status => Self::NotAuthenticated {
138                three_ds_server_trans_id,
139                trans_status: status,
140                reason_code: ares.trans_status_reason.map(|r| format!("{r:?}")),
141            },
142        };
143
144        Ok(next)
145    }
146
147    /// Build the CReq that should be submitted to the ACS challenge URL.
148    pub fn build_creq(
149        &self,
150        window_size: Option<crate::types::ChallengeWindowSize>,
151    ) -> Result<ChallengeRequest> {
152        let Self::AwaitingCRes {
153            three_ds_server_trans_id,
154            acs_trans_id,
155            ..
156        } = self
157        else {
158            return Err(Error::InvalidTransition {
159                from: self.name().to_owned(),
160                to: "CReq".to_owned(),
161            });
162        };
163
164        Ok(ChallengeRequest {
165            message_type: crate::message::creq::MessageType::CReq,
166            message_version: crate::types::MessageVersion::V220,
167            three_ds_server_trans_id: three_ds_server_trans_id.clone(),
168            acs_trans_id: acs_trans_id.clone(),
169            challenge_data_entry: None,
170            challenge_window_size: window_size,
171            challenge_completion_ind: None,
172            sdk_trans_id: None,
173            resend_challenge: None,
174            whitelist_status_source: None,
175        })
176    }
177
178    /// Process an incoming CRes and advance to a terminal state.
179    pub fn receive_cres(self, cres: ChallengeResponse) -> Result<Self> {
180        let Self::AwaitingCRes {
181            three_ds_server_trans_id,
182            acs_trans_id,
183            ..
184        } = self
185        else {
186            return Err(Error::InvalidTransition {
187                from: self.name().to_owned(),
188                to: "post-CRes".to_owned(),
189            });
190        };
191
192        let next = if cres.trans_status.is_authenticated() {
193            Self::Authenticated {
194                three_ds_server_trans_id,
195                acs_trans_id,
196                ds_trans_id: None,
197                eci: None,
198                authentication_value: None,
199            }
200        } else {
201            Self::NotAuthenticated {
202                three_ds_server_trans_id,
203                trans_status: cres.trans_status,
204                reason_code: None,
205            }
206        };
207
208        Ok(next)
209    }
210
211    /// Process an incoming RReq and advance to a terminal state.
212    /// The caller must send `ResultsResponse::acknowledge(&rreq)` to the ACS.
213    pub fn receive_rreq(self, rreq: ResultsRequest) -> Result<Self> {
214        let Self::AwaitingRReq {
215            three_ds_server_trans_id,
216            acs_trans_id,
217        } = self
218        else {
219            return Err(Error::InvalidTransition {
220                from: self.name().to_owned(),
221                to: "post-RReq".to_owned(),
222            });
223        };
224
225        if rreq.three_ds_server_trans_id != three_ds_server_trans_id {
226            return Err(Error::InvalidField {
227                field: "threeDSServerTransID",
228                reason: "RReq trans ID does not match AReq".to_owned(),
229            });
230        }
231
232        let next = if rreq.trans_status.is_authenticated() {
233            Self::Authenticated {
234                three_ds_server_trans_id,
235                acs_trans_id,
236                ds_trans_id: rreq.ds_trans_id,
237                eci: rreq.eci,
238                authentication_value: rreq.authentication_value,
239            }
240        } else {
241            Self::NotAuthenticated {
242                three_ds_server_trans_id,
243                trans_status: rreq.trans_status,
244                reason_code: rreq.trans_status_reason.map(|r| format!("{r:?}")),
245            }
246        };
247
248        Ok(next)
249    }
250
251    /// Handle a protocol error; always transitions to `Failed`.
252    pub fn receive_error(self, err: &ErrorMessage) -> Self {
253        Self::Failed {
254            error: format!(
255                "[{}] {}: {}",
256                err.error_code, err.error_description, err.error_detail
257            ),
258        }
259    }
260
261    /// Returns true if the transaction reached a terminal state.
262    pub fn is_terminal(&self) -> bool {
263        matches!(
264            self,
265            Self::Authenticated { .. } | Self::NotAuthenticated { .. } | Self::Failed { .. }
266        )
267    }
268
269    fn name(&self) -> &'static str {
270        match self {
271            Self::Created { .. } => "Created",
272            Self::AwaitingARes { .. } => "AwaitingARes",
273            Self::AwaitingCRes { .. } => "AwaitingCRes",
274            Self::AwaitingRReq { .. } => "AwaitingRReq",
275            Self::Authenticated { .. } => "Authenticated",
276            Self::NotAuthenticated { .. } => "NotAuthenticated",
277            Self::Failed { .. } => "Failed",
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::message::areq;
286    use crate::message::ares;
287    use crate::message::cres;
288    use crate::types::{DeviceChannel, MessageCategory, MessageVersion, TransStatus};
289
290    fn minimal_areq() -> AuthenticationRequest {
291        AuthenticationRequest {
292            message_type: areq::MessageType::AReq,
293            message_version: MessageVersion::V220,
294            three_ds_server_trans_id: "test-txn-id".to_owned(),
295            device_channel: DeviceChannel::Browser,
296            message_category: MessageCategory::PaymentAuthentication,
297            three_ds_requestor_id: "req-001".to_owned(),
298            three_ds_requestor_name: "Test Merchant".to_owned(),
299            three_ds_requestor_url: "https://merchant.example.com".to_owned(),
300            acct_number: "4111111111111111".to_owned(),
301            card_expiry_date: "2612".to_owned(),
302            three_ds_requestor_authentication_ind: None,
303            three_ds_requestor_authentication_info: None,
304            three_ds_requestor_challenge_ind: None,
305            three_ds_requestor_prior_authentication_info: None,
306            acct_type: None,
307            acct_info: None,
308            acct_id: None,
309            purchase_amount: Some("1000".to_owned()),
310            purchase_currency: Some("826".to_owned()),
311            purchase_exponent: Some("2".to_owned()),
312            purchase_date: Some("20261225120000".to_owned()),
313            trans_type: Some("01".to_owned()),
314            recurring_expiry: None,
315            recurring_frequency: None,
316            purchase_instal_data: None,
317            merchant_id: None,
318            mcc: None,
319            merchant_name: None,
320            merchant_country_code: None,
321            merchant_risk_indicator: None,
322            cardholder_name: None,
323            email: None,
324            home_phone: None,
325            mobile_phone: None,
326            work_phone: None,
327            bill_addr_city: None,
328            bill_addr_country: None,
329            bill_addr_line1: None,
330            bill_addr_line2: None,
331            bill_addr_line3: None,
332            bill_addr_post_code: None,
333            bill_addr_state: None,
334            ship_addr_city: None,
335            ship_addr_country: None,
336            ship_addr_line1: None,
337            ship_addr_line2: None,
338            ship_addr_line3: None,
339            ship_addr_post_code: None,
340            ship_addr_state: None,
341            addr_match: None,
342            three_ds_comp_ind: None,
343            notification_url: Some("https://merchant.example.com/3ds/notify".to_owned()),
344            browser_info: None,
345            sdk_info: None,
346            device_render_options: None,
347        }
348    }
349
350    fn frictionless_ares(trans_id: &str, status: TransStatus) -> AuthenticationResponse {
351        AuthenticationResponse {
352            message_type: ares::MessageType::ARes,
353            message_version: MessageVersion::V220,
354            three_ds_server_trans_id: trans_id.to_owned(),
355            acs_trans_id: "acs-001".to_owned(),
356            ds_trans_id: "ds-001".to_owned(),
357            trans_status: status,
358            trans_status_reason: None,
359            acs_challenge_mandated: None,
360            eci: Some(Eci::VisaFullyAuthenticated),
361            authentication_value: Some("abc123==".to_owned()),
362            acs_url: None,
363            acs_signed_content: None,
364            acs_dec_con_ind: None,
365            acs_reference_number: None,
366            ds_reference_number: None,
367            cardholder_info: None,
368            whitelist_status: None,
369            whitelist_status_source: None,
370        }
371    }
372
373    fn challenge_ares(trans_id: &str) -> AuthenticationResponse {
374        AuthenticationResponse {
375            message_type: ares::MessageType::ARes,
376            message_version: MessageVersion::V220,
377            three_ds_server_trans_id: trans_id.to_owned(),
378            acs_trans_id: "acs-001".to_owned(),
379            ds_trans_id: "ds-001".to_owned(),
380            trans_status: TransStatus::ChallengeRequired,
381            trans_status_reason: None,
382            acs_challenge_mandated: Some(ares::AcsMandated::Yes),
383            eci: None,
384            authentication_value: None,
385            acs_url: Some("https://acs.bank.com/challenge".to_owned()),
386            acs_signed_content: None,
387            acs_dec_con_ind: None,
388            acs_reference_number: None,
389            ds_reference_number: None,
390            cardholder_info: None,
391            whitelist_status: None,
392            whitelist_status_source: None,
393        }
394    }
395
396    #[test]
397    fn frictionless_success_flow() {
398        let state = TransactionState::new(minimal_areq());
399        let (state, _areq) = state.areq_sent().unwrap();
400        let state = state
401            .receive_ares(frictionless_ares("test-txn-id", TransStatus::Success))
402            .unwrap();
403        assert!(matches!(state, TransactionState::Authenticated { .. }));
404        assert!(state.is_terminal());
405    }
406
407    #[test]
408    fn frictionless_attempted_flow() {
409        let state = TransactionState::new(minimal_areq());
410        let (state, _) = state.areq_sent().unwrap();
411        let state = state
412            .receive_ares(frictionless_ares("test-txn-id", TransStatus::Attempted))
413            .unwrap();
414        assert!(matches!(state, TransactionState::Authenticated { .. }));
415    }
416
417    #[test]
418    fn frictionless_failure_flow() {
419        let state = TransactionState::new(minimal_areq());
420        let (state, _) = state.areq_sent().unwrap();
421        let state = state
422            .receive_ares(frictionless_ares("test-txn-id", TransStatus::Failure))
423            .unwrap();
424        assert!(matches!(state, TransactionState::NotAuthenticated { .. }));
425        assert!(state.is_terminal());
426    }
427
428    #[test]
429    fn challenge_success_flow() {
430        let state = TransactionState::new(minimal_areq());
431        let (state, _) = state.areq_sent().unwrap();
432        let state = state.receive_ares(challenge_ares("test-txn-id")).unwrap();
433        assert!(matches!(state, TransactionState::AwaitingCRes { .. }));
434
435        // Build and check the CReq
436        let creq = state.build_creq(None).unwrap();
437        assert_eq!(creq.three_ds_server_trans_id, "test-txn-id");
438        assert_eq!(creq.acs_trans_id, "acs-001");
439
440        let cres = ChallengeResponse {
441            message_type: cres::MessageType::CRes,
442            message_version: MessageVersion::V220,
443            three_ds_server_trans_id: "test-txn-id".to_owned(),
444            acs_trans_id: "acs-001".to_owned(),
445            trans_status: TransStatus::Success,
446            challenge_completion_ind: cres::CompletionIndicator::Complete,
447            acs_ui: None,
448            acs_ui_type: None,
449            acs_html: None,
450            whitelist_status: None,
451        };
452
453        let state = state.receive_cres(cres).unwrap();
454        assert!(matches!(state, TransactionState::Authenticated { .. }));
455    }
456
457    #[test]
458    fn trans_id_mismatch_is_error() {
459        let state = TransactionState::new(minimal_areq());
460        let (state, _) = state.areq_sent().unwrap();
461        let result = state.receive_ares(frictionless_ares("wrong-id", TransStatus::Success));
462        assert!(result.is_err());
463    }
464
465    #[test]
466    fn invalid_transition_from_created() {
467        let state = TransactionState::new(minimal_areq());
468        let result = state.receive_ares(frictionless_ares("test-txn-id", TransStatus::Success));
469        assert!(result.is_err());
470    }
471
472    #[test]
473    fn error_message_transitions_to_failed() {
474        let state = TransactionState::new(minimal_areq());
475        let (state, _) = state.areq_sent().unwrap();
476        let err = ErrorMessage {
477            message_type: crate::message::error_msg::MessageType::Erro,
478            message_version: MessageVersion::V220,
479            error_code: "202".to_owned(),
480            error_description: "Critical element missing".to_owned(),
481            error_detail: "acctNumber".to_owned(),
482            error_message_type: "AReq".to_owned(),
483            three_ds_server_trans_id: None,
484            acs_trans_id: None,
485            ds_trans_id: None,
486            sdk_trans_id: None,
487        };
488        let state = state.receive_error(&err);
489        assert!(matches!(state, TransactionState::Failed { .. }));
490        assert!(state.is_terminal());
491    }
492}