Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cards): validate card security code and expiration #874

Merged
merged 11 commits into from
May 3, 2023
22 changes: 17 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions crates/cards/Cargo.toml
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"
190 changes: 190 additions & 0 deletions crates/cards/src/lib.rs
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
}
}
101 changes: 101 additions & 0 deletions crates/cards/tests/basic.rs
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);
SanchithHegde marked this conversation as resolved.
Show resolved Hide resolved

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();
Comment on lines +10 to +21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker but prefer .expect("what was expected") over .unwrap() since it makes finding the cause of panic easier.

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());
}
2 changes: 2 additions & 0 deletions crates/masking/src/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub trait SerializableSecret: Serialize {}
// pub trait NonSerializableSecret: Serialize {}

impl SerializableSecret for serde_json::Value {}
impl SerializableSecret for u8 {}
impl SerializableSecret for u16 {}

impl<'de, T, I> Deserialize<'de> for Secret<T, I>
where
Expand Down