Skip to content

Commit

Permalink
feat(cards): validate card security code and expiration (#874)
Browse files Browse the repository at this point in the history
Co-authored-by: Sanchith Hegde <[email protected]>
  • Loading branch information
Naman Agarwal and SanchithHegde authored May 2, 2023
1 parent 9cbda83 commit 0b7bc7b
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 5 deletions.
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);

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());
}
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

0 comments on commit 0b7bc7b

Please sign in to comment.