Skip to content

Commit

Permalink
feat(cards): add credit card number validation (#889)
Browse files Browse the repository at this point in the history
Co-authored-by: Sanchith Hegde <[email protected]>
  • Loading branch information
phillyphil91 and SanchithHegde authored May 9, 2023
1 parent 0bb0437 commit d6e71b9
Show file tree
Hide file tree
Showing 79 changed files with 381 additions and 684 deletions.
23 changes: 21 additions & 2 deletions Cargo.lock

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

23 changes: 0 additions & 23 deletions connector-template/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,29 +302,6 @@ async fn should_fail_payment_for_incorrect_card_number() {
);
}

// Creates a payment with empty card number.
#[actix_web::test]
async fn should_fail_payment_for_empty_card_number() {
let response = CONNECTOR
.make_payment(
Some(types::PaymentsAuthorizeData {
payment_method_data: types::api::PaymentMethodData::Card(api::Card {
card_number: Secret::new(String::from("")),
..utils::CCardType::default().0
}),
..utils::PaymentAuthorizeType::default().0
}),
get_default_payment_info(),
)
.await
.unwrap();
let x = response.response.unwrap_err();
assert_eq!(
x.message,
"You passed an empty string for 'payment_method_data[card][number]'.",
);
}

// Creates a payment with incorrect CVC.
#[actix_web::test]
async fn should_fail_payment_for_incorrect_cvc() {
Expand Down
2 changes: 1 addition & 1 deletion connector-template/transformers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub struct {{project-name | downcase | pascal_case}}PaymentsRequest {
#[derive(Default, Debug, Serialize, Eq, PartialEq)]
pub struct {{project-name | downcase | pascal_case}}Card {
name: Secret<String>,
number: Secret<String, common_utils::pii::CardNumber>,
number: cards::CardNumber,
expiry_month: Secret<String>,
expiry_year: Secret<String>,
cvc: Secret<String>,
Expand Down
1 change: 1 addition & 0 deletions crates/api_models/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ common_utils = { version = "0.1.0", path = "../common_utils" }
masking = { version = "0.1.0", path = "../masking" }
router_derive = { version = "0.1.0", path = "../router_derive" }
common_enums = {path = "../common_enums"}
cards = { version = "0.1.0", path = "../cards" }
5 changes: 3 additions & 2 deletions crates/api_models/src/payment_methods.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use cards::CardNumber;
use common_utils::pii;
use serde::de;
use utoipa::ToSchema;
Expand Down Expand Up @@ -72,7 +73,7 @@ pub struct PaymentMethodUpdate {
pub struct CardDetail {
/// Card Number
#[schema(value_type = String,example = "4111111145551142")]
pub card_number: masking::Secret<String, pii::CardNumber>,
pub card_number: CardNumber,

/// Card Expiry Month
#[schema(value_type = String,example = "10")]
Expand Down Expand Up @@ -142,7 +143,7 @@ pub struct CardDetailFromLocker {
pub last4_digits: Option<String>,
#[serde(skip)]
#[schema(value_type=Option<String>)]
pub card_number: Option<masking::Secret<String, pii::CardNumber>>,
pub card_number: Option<CardNumber>,

#[schema(value_type=Option<String>)]
pub expiry_month: Option<masking::Secret<String>>,
Expand Down
5 changes: 3 additions & 2 deletions crates/api_models/src/payments.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::num::NonZeroI64;

use cards::CardNumber;
use common_utils::{pii, pii::Email};
use masking::{PeekInterface, Secret};
use router_derive::Setter;
Expand Down Expand Up @@ -415,7 +416,7 @@ pub struct OnlineMandate {
pub struct Card {
/// The card number
#[schema(value_type = String, example = "4242424242424242")]
pub card_number: Secret<String, pii::CardNumber>,
pub card_number: CardNumber,

/// The card's expiry month
#[schema(value_type = String, example = "24")]
Expand Down Expand Up @@ -580,7 +581,7 @@ pub enum BankRedirectData {
BancontactCard {
/// The card number
#[schema(value_type = String, example = "4242424242424242")]
card_number: Secret<String, pii::CardNumber>,
card_number: CardNumber,
/// The card's expiry month
#[schema(value_type = String, example = "24")]
card_exp_month: Secret<String>,
Expand Down
4 changes: 3 additions & 1 deletion crates/cards/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ default = ["serde"]
time = { version = "0.3.20" }
error-stack = "0.3.1"
serde = { version = "1", features = ["derive"], optional = true }
thiserror = "1.0.40"
luhn = "1.0.1"

# 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"
serde_json = "1.0.94"
3 changes: 3 additions & 0 deletions crates/cards/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod validate;
use std::ops::Deref;

use common_utils::{date_time, errors};
Expand All @@ -9,6 +10,8 @@ use serde::{
};
use time::{util::days_in_year_month, Date, Duration, PrimitiveDateTime, Time};

pub use crate::validate::{CCValError, CardNumber, CardNumberStrategy};

#[derive(Serialize)]
pub struct CardSecurityCode(StrongSecret<u16>);

Expand Down
143 changes: 143 additions & 0 deletions crates/cards/src/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use std::{fmt, ops::Deref, str::FromStr};

use masking::{Strategy, StrongSecret, WithType};
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;

#[derive(Debug, Deserialize, Serialize, Error)]
#[error("not a valid credit card number")]
pub struct CCValError;

impl From<core::convert::Infallible> for CCValError {
fn from(_: core::convert::Infallible) -> Self {
Self
}
}

/// Card number
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct CardNumber(StrongSecret<String, CardNumberStrategy>);

impl FromStr for CardNumber {
type Err = CCValError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match luhn::valid(s) {
true => {
let cc_no_whitespace: String = s.split_whitespace().collect();
Ok(Self(StrongSecret::from_str(&cc_no_whitespace)?))
}
false => Err(CCValError),
}
}
}

impl TryFrom<String> for CardNumber {
type Error = CCValError;

fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}

impl Deref for CardNumber {
type Target = StrongSecret<String, CardNumberStrategy>;

fn deref(&self) -> &StrongSecret<String, CardNumberStrategy> {
&self.0
}
}

impl<'de> Deserialize<'de> for CardNumber {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_str(&s).map_err(serde::de::Error::custom)
}
}

pub struct CardNumberStrategy;

impl<T> Strategy<T> for CardNumberStrategy
where
T: AsRef<str>,
{
fn fmt(val: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let val_str: &str = val.as_ref();

if val_str.len() < 15 || val_str.len() > 19 {
return WithType::fmt(val, f);
}

write!(f, "{}{}", &val_str[..6], "*".repeat(val_str.len() - 6))
}
}

#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]

use masking::Secret;

use super::*;

#[test]
fn valid_card_number() {
let s = "371449635398431";
assert_eq!(
CardNumber::from_str(s).unwrap(),
CardNumber(StrongSecret::from_str(s).unwrap())
);
}

#[test]
fn invalid_card_number() {
let s = "371446431";
assert_eq!(
CardNumber::from_str(s).unwrap_err().to_string(),
"not a valid credit card number".to_string()
);
}

#[test]
fn card_number_no_whitespace() {
let s = "3714 4963 5398 431";
assert_eq!(
CardNumber::from_str(s).unwrap().to_string(),
"371449*********"
);
}

#[test]
fn test_valid_card_number_masking() {
let secret: Secret<String, CardNumberStrategy> =
Secret::new("1234567890987654".to_string());
assert_eq!("123456**********", format!("{secret:?}"));
}

#[test]
fn test_invalid_card_number_masking() {
let secret: Secret<String, CardNumberStrategy> = Secret::new("1234567890".to_string());
assert_eq!("*** alloc::string::String ***", format!("{secret:?}"));
}

#[test]
fn test_valid_card_number_strong_secret_masking() {
let card_number = CardNumber::from_str("3714 4963 5398 431").unwrap();
let secret = &(*card_number);
assert_eq!("371449*********", format!("{secret:?}"));
}

#[test]
fn test_valid_card_number_deserialization() {
let card_number = serde_json::from_str::<CardNumber>(r#""3714 4963 5398 431""#).unwrap();
let secret = card_number.to_string();
assert_eq!(r#""371449*********""#, format!("{secret:?}"));
}

#[test]
fn test_invalid_card_number_deserialization() {
let card_number = serde_json::from_str::<CardNumber>(r#""1234 5678""#);
let error_msg = card_number.unwrap_err().to_string();
assert_eq!(error_msg, "not a valid credit card number".to_string());
}
}
33 changes: 1 addition & 32 deletions crates/common_utils/src/pii.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,6 @@ pub const REDACTED: &str = "Redacted";
/// Type alias for serde_json value which has Secret Information
pub type SecretSerdeValue = Secret<serde_json::Value>;

/// Card number
#[derive(Debug)]
pub struct CardNumber;

impl<T> Strategy<T> for CardNumber
where
T: AsRef<str>,
{
fn fmt(val: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let val_str: &str = val.as_ref();

if val_str.len() < 15 || val_str.len() > 19 {
return WithType::fmt(val, f);
}

write!(f, "{}{}", &val_str[..6], "*".repeat(val_str.len() - 6))
}
}

/*
/// Phone number
#[derive(Debug)]
Expand Down Expand Up @@ -226,21 +207,9 @@ mod pii_masking_strategy_tests {

use masking::{ExposeInterface, Secret};

use super::{CardNumber, ClientSecret, Email, IpAddress};
use super::{ClientSecret, Email, IpAddress};
use crate::pii::{EmailStrategy, REDACTED};

#[test]
fn test_valid_card_number_masking() {
let secret: Secret<String, CardNumber> = Secret::new("1234567890987654".to_string());
assert_eq!("123456**********", format!("{secret:?}"));
}

#[test]
fn test_invalid_card_number_masking() {
let secret: Secret<String, CardNumber> = Secret::new("1234567890".to_string());
assert_eq!("*** alloc::string::String ***", format!("{secret:?}"));
}

/*
#[test]
fn test_valid_phone_number_masking() {
Expand Down
1 change: 1 addition & 0 deletions crates/router/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ uuid = { version = "1.3.1", features = ["serde", "v4"] }
# First party crates
api_models = { version = "0.1.0", path = "../api_models", features = ["errors"] }
common_utils = { version = "0.1.0", path = "../common_utils", features = ["signals", "async_ext"] }
cards = { version = "0.1.0", path = "../cards" }
external_services = { version = "0.1.0", path = "../external_services" }
masking = { version = "0.1.0", path = "../masking" }
redis_interface = { version = "0.1.0", path = "../redis_interface" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl From<StripeBillingDetails> for payments::Address {

#[derive(Default, Serialize, PartialEq, Eq, Deserialize, Clone)]
pub struct StripeCard {
pub number: pii::Secret<String, pii::CardNumber>,
pub number: cards::CardNumber,
pub exp_month: pii::Secret<String>,
pub exp_year: pii::Secret<String>,
pub cvc: pii::Secret<String>,
Expand Down
Loading

0 comments on commit d6e71b9

Please sign in to comment.