-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cards): validate card security code and expiration (#874)
Co-authored-by: Sanchith Hegde <[email protected]>
- Loading branch information
1 parent
9cbda83
commit 0b7bc7b
Showing
5 changed files
with
331 additions
and
5 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
[package] | ||
name = "cards" | ||
version = "0.1.0" | ||
edition = "2021" | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[features] | ||
default = ["serde"] | ||
|
||
[dependencies] | ||
time = { version = "0.3.20" } | ||
error-stack = "0.3.1" | ||
serde = { version = "1", features = ["derive"], optional = true } | ||
|
||
# First Party crates | ||
masking = { version = "0.1.0", path = "../masking" } | ||
common_utils = { version = "0.1.0", path = "../common_utils" } | ||
|
||
[dev-dependencies] | ||
serde_json = "1.0.94" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
use std::ops::Deref; | ||
|
||
use common_utils::{date_time, errors}; | ||
use error_stack::report; | ||
use masking::{PeekInterface, StrongSecret}; | ||
use serde::{ | ||
de::{self}, | ||
Deserialize, Serialize, | ||
}; | ||
use time::{util::days_in_year_month, Date, Duration, PrimitiveDateTime, Time}; | ||
|
||
#[derive(Serialize)] | ||
pub struct CardSecurityCode(StrongSecret<u16>); | ||
|
||
impl TryFrom<u16> for CardSecurityCode { | ||
type Error = error_stack::Report<errors::ValidationError>; | ||
fn try_from(csc: u16) -> Result<Self, Self::Error> { | ||
if (100..=9999).contains(&csc) { | ||
Ok(Self(StrongSecret::new(csc))) | ||
} else { | ||
Err(report!(errors::ValidationError::InvalidValue { | ||
message: "invalid card security code".to_string() | ||
})) | ||
} | ||
} | ||
} | ||
|
||
impl<'de> Deserialize<'de> for CardSecurityCode { | ||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||
where | ||
D: serde::Deserializer<'de>, | ||
{ | ||
let csc = u16::deserialize(deserializer)?; | ||
csc.try_into().map_err(de::Error::custom) | ||
} | ||
} | ||
|
||
#[derive(Serialize)] | ||
pub struct CardExpirationMonth(StrongSecret<u8>); | ||
|
||
impl CardExpirationMonth { | ||
pub fn two_digits(&self) -> String { | ||
format!("{:02}", self.peek()) | ||
} | ||
} | ||
|
||
impl TryFrom<u8> for CardExpirationMonth { | ||
type Error = error_stack::Report<errors::ValidationError>; | ||
fn try_from(month: u8) -> Result<Self, Self::Error> { | ||
if (1..=12).contains(&month) { | ||
Ok(Self(StrongSecret::new(month))) | ||
} else { | ||
Err(report!(errors::ValidationError::InvalidValue { | ||
message: "invalid card expiration month".to_string() | ||
})) | ||
} | ||
} | ||
} | ||
|
||
impl<'de> Deserialize<'de> for CardExpirationMonth { | ||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||
where | ||
D: serde::Deserializer<'de>, | ||
{ | ||
let month = u8::deserialize(deserializer)?; | ||
month.try_into().map_err(de::Error::custom) | ||
} | ||
} | ||
|
||
#[derive(Serialize)] | ||
pub struct CardExpirationYear(StrongSecret<u16>); | ||
|
||
impl CardExpirationYear { | ||
pub fn four_digits(&self) -> String { | ||
self.peek().to_string() | ||
} | ||
|
||
pub fn two_digits(&self) -> String { | ||
let year = self.peek() % 100; | ||
year.to_string() | ||
} | ||
} | ||
|
||
impl TryFrom<u16> for CardExpirationYear { | ||
type Error = error_stack::Report<errors::ValidationError>; | ||
fn try_from(year: u16) -> Result<Self, Self::Error> { | ||
let curr_year = u16::try_from(date_time::now().year()).map_err(|_| { | ||
report!(errors::ValidationError::InvalidValue { | ||
message: "invalid year".to_string() | ||
}) | ||
})?; | ||
|
||
if year >= curr_year { | ||
Ok(Self(StrongSecret::<u16>::new(year))) | ||
} else { | ||
Err(report!(errors::ValidationError::InvalidValue { | ||
message: "invalid card expiration year".to_string() | ||
})) | ||
} | ||
} | ||
} | ||
|
||
impl<'de> Deserialize<'de> for CardExpirationYear { | ||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||
where | ||
D: serde::Deserializer<'de>, | ||
{ | ||
let year = u16::deserialize(deserializer)?; | ||
year.try_into().map_err(de::Error::custom) | ||
} | ||
} | ||
|
||
#[derive(Serialize, Deserialize)] | ||
pub struct CardExpiration { | ||
pub month: CardExpirationMonth, | ||
pub year: CardExpirationYear, | ||
} | ||
|
||
impl CardExpiration { | ||
pub fn is_expired(&self) -> Result<bool, error_stack::Report<errors::ValidationError>> { | ||
let current_datetime_utc = date_time::now(); | ||
|
||
let expiration_month = (*self.month.peek()).try_into().map_err(|_| { | ||
report!(errors::ValidationError::InvalidValue { | ||
message: "invalid month".to_string() | ||
}) | ||
})?; | ||
|
||
let expiration_year = *self.year.peek(); | ||
|
||
let expiration_day = days_in_year_month(i32::from(expiration_year), expiration_month); | ||
|
||
let expiration_date = | ||
Date::from_calendar_date(i32::from(expiration_year), expiration_month, expiration_day) | ||
.map_err(|_| { | ||
report!(errors::ValidationError::InvalidValue { | ||
message: "error while constructing calendar date".to_string() | ||
}) | ||
})?; | ||
|
||
let expiration_time = Time::MIDNIGHT; | ||
|
||
// actual expiry date specified on card w.r.t. local timezone | ||
// max diff b/w utc and other timezones is 14 hours | ||
let mut expiration_datetime_utc = PrimitiveDateTime::new(expiration_date, expiration_time); | ||
|
||
// compensating time difference b/w local and utc timezone by adding a day | ||
expiration_datetime_utc = expiration_datetime_utc.saturating_add(Duration::days(1)); | ||
|
||
Ok(current_datetime_utc > expiration_datetime_utc) | ||
} | ||
|
||
pub fn get_month(&self) -> &CardExpirationMonth { | ||
&self.month | ||
} | ||
|
||
pub fn get_year(&self) -> &CardExpirationYear { | ||
&self.year | ||
} | ||
} | ||
|
||
impl TryFrom<(u8, u16)> for CardExpiration { | ||
type Error = error_stack::Report<errors::ValidationError>; | ||
fn try_from(items: (u8, u16)) -> errors::CustomResult<Self, errors::ValidationError> { | ||
let month = CardExpirationMonth::try_from(items.0)?; | ||
let year = CardExpirationYear::try_from(items.1)?; | ||
Ok(Self { month, year }) | ||
} | ||
} | ||
|
||
impl Deref for CardSecurityCode { | ||
type Target = StrongSecret<u16>; | ||
fn deref(&self) -> &Self::Target { | ||
&self.0 | ||
} | ||
} | ||
|
||
impl Deref for CardExpirationMonth { | ||
type Target = StrongSecret<u8>; | ||
fn deref(&self) -> &Self::Target { | ||
&self.0 | ||
} | ||
} | ||
|
||
impl Deref for CardExpirationYear { | ||
type Target = StrongSecret<u16>; | ||
fn deref(&self) -> &Self::Target { | ||
&self.0 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
#![allow(clippy::unwrap_used, clippy::expect_used)] | ||
|
||
use cards::{CardExpiration, CardExpirationMonth, CardExpirationYear, CardSecurityCode}; | ||
use common_utils::date_time; | ||
use masking::PeekInterface; | ||
|
||
#[test] | ||
fn test_card_security_code() { | ||
// no panic | ||
let valid_card_security_code = CardSecurityCode::try_from(1234).unwrap(); | ||
|
||
// will panic on unwrap | ||
let invalid_card_security_code = CardSecurityCode::try_from(12); | ||
|
||
assert_eq!(*valid_card_security_code.peek(), 1234); | ||
assert!(invalid_card_security_code.is_err()); | ||
|
||
let serialized = serde_json::to_string(&valid_card_security_code).unwrap(); | ||
assert_eq!(serialized, "1234"); | ||
|
||
let derialized = serde_json::from_str::<CardSecurityCode>(&serialized).unwrap(); | ||
assert_eq!(*derialized.peek(), 1234); | ||
|
||
let invalid_deserialization = serde_json::from_str::<CardSecurityCode>("12"); | ||
assert!(invalid_deserialization.is_err()); | ||
} | ||
|
||
#[test] | ||
fn test_card_expiration_month() { | ||
// no panic | ||
let card_exp_month = CardExpirationMonth::try_from(12).unwrap(); | ||
|
||
// will panic on unwrap | ||
let invalid_card_exp_month = CardExpirationMonth::try_from(13); | ||
|
||
assert_eq!(*card_exp_month.peek(), 12); | ||
assert!(invalid_card_exp_month.is_err()); | ||
|
||
let serialized = serde_json::to_string(&card_exp_month).unwrap(); | ||
assert_eq!(serialized, "12"); | ||
|
||
let derialized = serde_json::from_str::<CardExpirationMonth>(&serialized).unwrap(); | ||
assert_eq!(*derialized.peek(), 12); | ||
|
||
let invalid_deserialization = serde_json::from_str::<CardExpirationMonth>("13"); | ||
assert!(invalid_deserialization.is_err()); | ||
} | ||
|
||
#[test] | ||
fn test_card_expiration_year() { | ||
let curr_date = date_time::now(); | ||
let curr_year = u16::try_from(curr_date.year()).expect("valid year"); | ||
|
||
// no panic | ||
let card_exp_year = CardExpirationYear::try_from(curr_year).unwrap(); | ||
|
||
// will panic on unwrap | ||
let invalid_card_exp_year = CardExpirationYear::try_from(curr_year - 1); | ||
|
||
assert_eq!(*card_exp_year.peek(), curr_year); | ||
assert!(invalid_card_exp_year.is_err()); | ||
|
||
let serialized = serde_json::to_string(&card_exp_year).unwrap(); | ||
assert_eq!(serialized, curr_year.to_string()); | ||
|
||
let derialized = serde_json::from_str::<CardExpirationYear>(&serialized).unwrap(); | ||
assert_eq!(*derialized.peek(), curr_year); | ||
|
||
let invalid_deserialization = serde_json::from_str::<CardExpirationYear>("123"); | ||
assert!(invalid_deserialization.is_err()); | ||
} | ||
|
||
#[test] | ||
fn test_card_expiration() { | ||
let curr_date = date_time::now(); | ||
let curr_year = u16::try_from(curr_date.year()).expect("valid year"); | ||
|
||
// no panic | ||
let card_exp = CardExpiration::try_from((3, curr_year + 1)).unwrap(); | ||
|
||
// will panic on unwrap | ||
let invalid_card_exp = CardExpiration::try_from((13, curr_year + 1)); | ||
|
||
assert_eq!(*card_exp.get_month().peek(), 3); | ||
assert_eq!(*card_exp.get_year().peek(), curr_year + 1); | ||
assert!(card_exp.is_expired().unwrap()); | ||
|
||
assert!(invalid_card_exp.is_err()); | ||
|
||
let serialized = serde_json::to_string(&card_exp).unwrap(); | ||
let expected_string = format!(r#"{{"month":{},"year":{}}}"#, 3, curr_year + 1); | ||
assert_eq!(serialized, expected_string); | ||
|
||
let derialized = serde_json::from_str::<CardExpiration>(&serialized).unwrap(); | ||
assert_eq!(*derialized.get_month().peek(), 3); | ||
assert_eq!(*derialized.get_year().peek(), curr_year + 1); | ||
|
||
let invalid_serialized_string = r#"{"month":13,"year":123}"#; | ||
let invalid_deserialization = serde_json::from_str::<CardExpiration>(invalid_serialized_string); | ||
assert!(invalid_deserialization.is_err()); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters