1use crate::error::{Error, Result};
2use crate::message::{
3 AuthenticationRequest, AuthenticationResponse, ChallengeRequest, ChallengeResponse,
4 ErrorMessage, ResultsRequest,
5};
6use crate::types::{Eci, TransStatus};
7
8#[derive(Debug, Clone)]
39pub enum TransactionState {
40 Created { areq: Box<AuthenticationRequest> },
42 AwaitingARes { three_ds_server_trans_id: String },
44 AwaitingCRes {
46 three_ds_server_trans_id: String,
47 acs_trans_id: String,
48 acs_url: Option<String>,
50 },
51 AwaitingRReq {
54 three_ds_server_trans_id: String,
55 acs_trans_id: String,
56 },
57 Authenticated {
59 three_ds_server_trans_id: String,
60 acs_trans_id: String,
61 ds_trans_id: Option<String>,
62 eci: Option<Eci>,
63 authentication_value: Option<String>,
65 },
66 NotAuthenticated {
68 three_ds_server_trans_id: String,
69 trans_status: TransStatus,
70 reason_code: Option<String>,
71 },
72 Failed { error: String },
74}
75
76impl TransactionState {
77 pub fn new(areq: AuthenticationRequest) -> Self {
79 Self::Created {
80 areq: Box::new(areq),
81 }
82 }
83
84 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 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 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 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 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 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 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 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}