Bug: [FIX] address non-digit character cases in card number validation



Enforce following validations on the card number -
* non-digit characters in card number 
* invalid card number length
* invalid card number (luhn failure)

If any of the above validation fails, raise an appropriate error.
diff --git a/Cargo.lock b/Cargo.lock
index 5ba2d0dae6d..abc265ad692 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1728,7 +1728,6 @@ version = "0.1.0"
 dependencies = [
  "common_utils",
  "error-stack",
- "luhn",
  "masking",
  "router_env",
  "serde",
@@ -2598,12 +2597,6 @@ dependencies = [
  "subtle",
 ]
 
-[[package]]
-name = "digits_iterator"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af83450b771231745d43edf36dc9b7813ab83be5e8cbea344ccced1a09dfebcd"
-
 [[package]]
 name = "displaydoc"
 version = "0.2.4"
@@ -4089,15 +4082,6 @@ dependencies = [
  "linked-hash-map",
 ]
 
-[[package]]
-name = "luhn"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d10b831402a3b10e018c8bc7f0ec3344a67d0725919cbaf393accb9baf8700b"
-dependencies = [
- "digits_iterator",
-]
-
 [[package]]
 name = "masking"
 version = "0.1.0"
diff --git a/crates/cards/Cargo.toml b/crates/cards/Cargo.toml
index ac8a417fb99..ed2be966559 100644
--- a/crates/cards/Cargo.toml
+++ b/crates/cards/Cargo.toml
@@ -11,7 +11,6 @@ license.workspace = true
 
 [dependencies]
 error-stack = "0.4.1"
-luhn = "1.0.1"
 serde = { version = "1.0.197", features = ["derive"] }
 thiserror = "1.0.58"
 time = "0.3.35"
diff --git a/crates/cards/src/lib.rs b/crates/cards/src/lib.rs
index 5308718826a..91cb93301a9 100644
--- a/crates/cards/src/lib.rs
+++ b/crates/cards/src/lib.rs
@@ -7,7 +7,7 @@ use masking::{PeekInterface, StrongSecret};
 use serde::{de, Deserialize, Serialize};
 use time::{util::days_in_year_month, Date, Duration, PrimitiveDateTime, Time};
 
-pub use crate::validate::{CCValError, CardNumber, CardNumberStrategy};
+pub use crate::validate::{CardNumber, CardNumberStrategy, CardNumberValidationErr};
 
 #[derive(Serialize)]
 pub struct CardSecurityCode(StrongSecret<u16>);
diff --git a/crates/cards/src/validate.rs b/crates/cards/src/validate.rs
index 5a71aaa7863..1eb07599785 100644
--- a/crates/cards/src/validate.rs
+++ b/crates/cards/src/validate.rs
@@ -6,31 +6,36 @@ use router_env::{logger, which as router_env_which, Env};
 use serde::{Deserialize, Deserializer, Serialize};
 use thiserror::Error;
 
-#[derive(Debug, Deserialize, Serialize, Error)]
-#[error("not a valid credit card number")]
-pub struct CCValError;
+///
+/// Minimum limit of a card number will not be less than 8 by ISO standards
+///
+pub const MIN_CARD_NUMBER_LENGTH: usize = 8;
 
-impl From<core::convert::Infallible> for CCValError {
-    fn from(_: core::convert::Infallible) -> Self {
-        Self
-    }
-}
+///
+/// Maximum limit of a card number will not exceed 19 by ISO standards
+///
+pub const MAX_CARD_NUMBER_LENGTH: usize = 19;
+
+#[derive(Debug, Deserialize, Serialize, Error)]
+#[error("{0}")]
+pub struct CardNumberValidationErr(&'static str);
 
 /// Card number
 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
 pub struct CardNumber(StrongSecret<String, CardNumberStrategy>);
 
 impl CardNumber {
-    pub fn get_card_isin(self) -> String {
+    pub fn get_card_isin(&self) -> String {
         self.0.peek().chars().take(6).collect::<String>()
     }
-    pub fn get_extended_card_bin(self) -> String {
+
+    pub fn get_extended_card_bin(&self) -> String {
         self.0.peek().chars().take(8).collect::<String>()
     }
-    pub fn get_card_no(self) -> String {
+    pub fn get_card_no(&self) -> String {
         self.0.peek().chars().collect::<String>()
     }
-    pub fn get_last4(self) -> String {
+    pub fn get_last4(&self) -> String {
         self.0
             .peek()
             .chars()
@@ -44,9 +49,9 @@ impl CardNumber {
 }
 
 impl FromStr for CardNumber {
-    type Err = CCValError;
+    type Err = CardNumberValidationErr;
 
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
+    fn from_str(card_number: &str) -> Result<Self, Self::Err> {
         // Valid test cards for threedsecureio
         let valid_test_cards = vec![
             "4000100511112003",
@@ -59,17 +64,79 @@ impl FromStr for CardNumber {
             Env::Development | Env::Sandbox => valid_test_cards,
             Env::Production => vec![],
         };
-        if luhn::valid(s) || valid_test_cards.contains(&s) {
-            let cc_no_whitespace: String = s.split_whitespace().collect();
-            Ok(Self(StrongSecret::from_str(&cc_no_whitespace)?))
+
+        let card_number = card_number.split_whitespace().collect::<String>();
+
+        let is_card_valid = sanitize_card_number(&card_number)?;
+
+        if valid_test_cards.contains(&card_number.as_str()) || is_card_valid {
+            Ok(Self(StrongSecret::new(card_number)))
         } else {
-            Err(CCValError)
+            Err(CardNumberValidationErr("card number invalid"))
         }
     }
 }
 
+pub fn sanitize_card_number(card_number: &str) -> Result<bool, CardNumberValidationErr> {
+    let is_card_number_valid = Ok(card_number)
+        .and_then(validate_card_number_chars)
+        .and_then(validate_card_number_length)
+        .map(|number| luhn(&number))?;
+
+    Ok(is_card_number_valid)
+}
+
+///
+/// # Panics
+///
+/// Never, as a single character will never be greater than 10, or `u8`
+///
+pub fn validate_card_number_chars(number: &str) -> Result<Vec<u8>, CardNumberValidationErr> {
+    let data = number.chars().try_fold(
+        Vec::with_capacity(MAX_CARD_NUMBER_LENGTH),
+        |mut data, character| {
+            data.push(
+                #[allow(clippy::expect_used)]
+                character
+                    .to_digit(10)
+                    .ok_or(CardNumberValidationErr(
+                        "invalid character found in card number",
+                    ))?
+                    .try_into()
+                    .expect("error while converting a single character to u8"), // safety, a single character will never be greater `u8`
+            );
+            Ok::<Vec<u8>, CardNumberValidationErr>(data)
+        },
+    )?;
+
+    Ok(data)
+}
+
+pub fn validate_card_number_length(number: Vec<u8>) -> Result<Vec<u8>, CardNumberValidationErr> {
+    if number.len() >= MIN_CARD_NUMBER_LENGTH && number.len() <= MAX_CARD_NUMBER_LENGTH {
+        Ok(number)
+    } else {
+        Err(CardNumberValidationErr("invalid card number length"))
+    }
+}
+
+#[allow(clippy::as_conversions)]
+pub fn luhn(number: &[u8]) -> bool {
+    number
+        .iter()
+        .rev()
+        .enumerate()
+        .map(|(idx, element)| {
+            ((*element * 2) / 10 + (*element * 2) % 10) * ((idx as u8) % 2)
+                + (*element) * (((idx + 1) as u8) % 2)
+        })
+        .sum::<u8>()
+        % 10
+        == 0
+}
+
 impl TryFrom<String> for CardNumber {
-    type Error = CCValError;
+    type Error = CardNumberValidationErr;
 
     fn try_from(value: String) -> Result<Self, Self::Error> {
         Self::from_str(&value)
@@ -131,12 +198,30 @@ mod tests {
         );
     }
 
+    #[test]
+    fn invalid_card_number_length() {
+        let s = "371446";
+        assert_eq!(
+            CardNumber::from_str(s).unwrap_err().to_string(),
+            "invalid card number length".to_string()
+        );
+    }
+
+    #[test]
+    fn card_number_with_non_digit_character() {
+        let s = "371446431 A";
+        assert_eq!(
+            CardNumber::from_str(s).unwrap_err().to_string(),
+            "invalid character found in card number".to_string()
+        );
+    }
+
     #[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()
+            "card number invalid".to_string()
         );
     }
 
@@ -180,6 +265,6 @@ mod tests {
     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());
+        assert_eq!(error_msg, "card number invalid".to_string());
     }
 }
diff --git a/crates/router/src/core/blocklist/utils.rs b/crates/router/src/core/blocklist/utils.rs
index 7c01a9aca76..bc8724da823 100644
--- a/crates/router/src/core/blocklist/utils.rs
+++ b/crates/router/src/core/blocklist/utils.rs
@@ -319,7 +319,7 @@ where
     {
         generate_fingerprint(
             state,
-            StrongSecret::new(card.card_number.clone().get_card_no()),
+            StrongSecret::new(card.card_number.get_card_no()),
             StrongSecret::new(merchant_fingerprint_secret.clone()),
             api_models::enums::LockerChoice::HyperswitchCardVault,
         )
@@ -343,7 +343,7 @@ where
         .as_ref()
         .and_then(|pm_data| match pm_data {
             api_models::payments::PaymentMethodData::Card(card) => {
-                Some(card.card_number.clone().get_card_isin())
+                Some(card.card_number.get_card_isin())
             }
             _ => None,
         });
@@ -355,7 +355,7 @@ where
             .as_ref()
             .and_then(|pm_data| match pm_data {
                 api_models::payments::PaymentMethodData::Card(card) => {
-                    Some(card.card_number.clone().get_extended_card_bin())
+                    Some(card.card_number.get_extended_card_bin())
                 }
                 _ => None,
             });
@@ -464,7 +464,7 @@ pub async fn generate_payment_fingerprint(
         {
             generate_fingerprint(
                 state,
-                StrongSecret::new(card.card_number.clone().get_card_no()),
+                StrongSecret::new(card.card_number.get_card_no()),
                 StrongSecret::new(merchant_fingerprint_secret),
                 api_models::enums::LockerChoice::HyperswitchCardVault,
             )
diff --git a/crates/router/src/core/payment_methods/cards.rs b/crates/router/src/core/payment_methods/cards.rs
index d3870a9e598..f7667baefa1 100644
--- a/crates/router/src/core/payment_methods/cards.rs
+++ b/crates/router/src/core/payment_methods/cards.rs
@@ -427,7 +427,7 @@ pub async fn add_payment_method_data(
                         pm_resp.payment_method_id.clone_from(&pm_id);
                         pm_resp.client_secret = Some(client_secret.clone());
 
-                        let card_isin = card.card_number.clone().get_card_isin();
+                        let card_isin = card.card_number.get_card_isin();
 
                         let card_info = db
                             .get_card_info(card_isin.as_str())
@@ -439,7 +439,7 @@ pub async fn add_payment_method_data(
                             issuer_country: card_info
                                 .as_ref()
                                 .and_then(|ci| ci.card_issuing_country.clone()),
-                            last4_digits: Some(card.card_number.clone().get_last4()),
+                            last4_digits: Some(card.card_number.get_last4()),
                             expiry_month: Some(card.card_exp_month),
                             expiry_year: Some(card.card_exp_year),
                             nick_name: card.nick_name,
@@ -644,7 +644,7 @@ pub async fn add_payment_method(
 
                     let updated_card = Some(api::CardDetailFromLocker {
                         scheme: None,
-                        last4_digits: Some(card.card_number.clone().get_last4()),
+                        last4_digits: Some(card.card_number.get_last4()),
                         issuer_country: None,
                         card_number: Some(card.card_number),
                         expiry_month: Some(card.card_exp_month),
@@ -894,7 +894,7 @@ pub async fn update_customer_payment_method(
             // Construct new updated card object. Consider a field if passed in request or else populate it with the existing value from existing_card_data
             let updated_card = Some(api::CardDetailFromLocker {
                 scheme: existing_card_data.scheme,
-                last4_digits: Some(card_data_from_locker.card_number.clone().get_last4()),
+                last4_digits: Some(card_data_from_locker.card_number.get_last4()),
                 issuer_country: existing_card_data.issuer_country,
                 card_number: existing_card_data.card_number,
                 expiry_month: card_update
diff --git a/crates/router/src/core/payment_methods/transformers.rs b/crates/router/src/core/payment_methods/transformers.rs
index 009e9e54467..08e87b47f91 100644
--- a/crates/router/src/core/payment_methods/transformers.rs
+++ b/crates/router/src/core/payment_methods/transformers.rs
@@ -339,7 +339,7 @@ pub fn mk_add_card_response_hs(
     merchant_id: &str,
 ) -> api::PaymentMethodResponse {
     let card_number = card.card_number.clone();
-    let last4_digits = card_number.clone().get_last4();
+    let last4_digits = card_number.get_last4();
     let card_isin = card_number.get_card_isin();
 
     let card = api::CardDetailFromLocker {
@@ -550,13 +550,13 @@ pub fn get_card_detail(
     response: Card,
 ) -> CustomResult<api::CardDetailFromLocker, errors::VaultError> {
     let card_number = response.card_number;
-    let mut last4_digits = card_number.peek().to_owned();
+    let last4_digits = card_number.clone().get_last4();
     //fetch form card bin
 
     let card_detail = api::CardDetailFromLocker {
         scheme: pm.scheme.to_owned(),
         issuer_country: pm.issuer_country.clone(),
-        last4_digits: Some(last4_digits.split_off(last4_digits.len() - 4)),
+        last4_digits: Some(last4_digits),
         card_number: Some(card_number),
         expiry_month: Some(response.card_exp_month),
         expiry_year: Some(response.card_exp_year),
diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs
index 38acf574a7c..6f3dd1d33df 100644
--- a/crates/router/src/core/payments/helpers.rs
+++ b/crates/router/src/core/payments/helpers.rs
@@ -3710,7 +3710,7 @@ pub async fn get_additional_payment_data(
 ) -> api_models::payments::AdditionalPaymentData {
     match pm_data {
         api_models::payments::PaymentMethodData::Card(card_data) => {
-            let card_isin = Some(card_data.card_number.clone().get_card_isin());
+            let card_isin = Some(card_data.card_number.get_card_isin());
             let enable_extended_bin =db
             .find_config_by_key_unwrap_or(
                 format!("{}_enable_extended_card_bin", profile_id).as_str(),
@@ -3719,11 +3719,11 @@ pub async fn get_additional_payment_data(
 
             let card_extended_bin = match enable_extended_bin {
                 Some(config) if config.config == "true" => {
-                    Some(card_data.card_number.clone().get_extended_card_bin())
+                    Some(card_data.card_number.get_extended_card_bin())
                 }
                 _ => None,
             };
-            let last4 = Some(card_data.card_number.clone().get_last4());
+            let last4 = Some(card_data.card_number.get_last4());
             if card_data.card_issuer.is_some()
                 && card_data.card_network.is_some()
                 && card_data.card_type.is_some()
diff --git a/crates/router/src/core/payments/tokenization.rs b/crates/router/src/core/payments/tokenization.rs
index 081dac14df0..f5a4265a9fb 100644
--- a/crates/router/src/core/payments/tokenization.rs
+++ b/crates/router/src/core/payments/tokenization.rs
@@ -470,7 +470,7 @@ where
 
                                 let updated_card = Some(CardDetailFromLocker {
                                     scheme: None,
-                                    last4_digits: Some(card.card_number.clone().get_last4()),
+                                    last4_digits: Some(card.card_number.get_last4()),
                                     issuer_country: None,
                                     card_number: Some(card.card_number),
                                     expiry_month: Some(card.card_exp_month),
diff --git a/crates/router/src/core/payouts/helpers.rs b/crates/router/src/core/payouts/helpers.rs
index c22f74bab4a..e8baba0d126 100644
--- a/crates/router/src/core/payouts/helpers.rs
+++ b/crates/router/src/core/payouts/helpers.rs
@@ -371,9 +371,7 @@ pub async fn save_payout_data_to_locker(
             metadata_update.as_ref(),
         ) {
             // Fetch card info from db
-            let card_isin = card_details
-                .as_ref()
-                .map(|c| c.card_number.clone().get_card_isin());
+            let card_isin = card_details.as_ref().map(|c| c.card_number.get_card_isin());
 
             let mut payment_method = api::PaymentMethodCreate {
                 payment_method: Some(api_enums::PaymentMethod::foreign_from(
@@ -410,9 +408,7 @@ pub async fn save_payout_data_to_locker(
                         card_info.card_network.clone().map(|cn| cn.to_string());
                     api::payment_methods::PaymentMethodsData::Card(
                         api::payment_methods::CardDetailsPaymentMethod {
-                            last4_digits: card_details
-                                .as_ref()
-                                .map(|c| c.card_number.clone().get_last4()),
+                            last4_digits: card_details.as_ref().map(|c| c.card_number.get_last4()),
                             issuer_country: card_info.card_issuing_country,
                             expiry_month: card_details.as_ref().map(|c| c.card_exp_month.clone()),
                             expiry_year: card_details.as_ref().map(|c| c.card_exp_year.clone()),
@@ -432,9 +428,7 @@ pub async fn save_payout_data_to_locker(
                 .unwrap_or_else(|| {
                     api::payment_methods::PaymentMethodsData::Card(
                         api::payment_methods::CardDetailsPaymentMethod {
-                            last4_digits: card_details
-                                .as_ref()
-                                .map(|c| c.card_number.clone().get_last4()),
+                            last4_digits: card_details.as_ref().map(|c| c.card_number.get_last4()),
                             issuer_country: None,
                             expiry_month: card_details.as_ref().map(|c| c.card_exp_month.clone()),
                             expiry_year: card_details.as_ref().map(|c| c.card_exp_year.clone()),

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [x] Bugfix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
<!-- Describe your changes in detail -->
This PR enforces following validations on the card number -
* non-digit characters in card number 
* invalid card number length
* invalid card number (luhn failure)

If any of the above validation fails, appropriate error is raised.
This PR also fixes the failing CI check.

### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
1. Unit tests - 

![image](https://github.com/juspay/hyperswitch/assets/70657455/bfa28c38-a208-4e0c-85d1-82402c08e6f8)

2. Invalid card number length - 

```
curl --location 'http://localhost:8080/payment_methods' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ekfmmqSYoY0bvjOGGO0Pe5CQdP9oS0ycImpRG78tjNTrgp03HGxvDAhHCrmHrYoO' \
--data '{
  "payment_method": "card",
  "payment_method_type": "credit",
  "payment_method_issuer": "Visa",
  "card": {
    "card_number": "1234567",
    "card_exp_month": "10",
    "card_exp_year": "26",
    "card_holder_name": "Chethan"
  },
  "customer_id": "cus_Cbszk87dvhH7EuBp2ZoB",
  "metadata": {
    "city": "NY",
    "unit": "245"
  }
}'
```

![image](https://github.com/juspay/hyperswitch/assets/70657455/2150eade-534c-4fb4-8d64-416119613f7f)

3. Non-digit character in card number

```
curl --location 'http://localhost:8080/payment_methods' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ekfmmqSYoY0bvjOGGO0Pe5CQdP9oS0ycImpRG78tjNTrgp03HGxvDAhHCrmHrYoO' \
--data '{
  "payment_method": "card",
  "payment_method_type": "credit",
  "payment_method_issuer": "Visa",
  "card": {
    "card_number": "411111111111111🦀",
    "card_exp_month": "10",
    "card_exp_year": "26",
    "card_holder_name": "Chethan"
  },
  "customer_id": "cus_Cbszk87dvhH7EuBp2ZoB",
  "metadata": {
    "city": "NY",
    "unit": "245"
  }
}'
```

![image](https://github.com/juspay/hyperswitch/assets/70657455/284d2e28-c9d6-4779-8aa3-31d2f2ada249)


4. Invalid card number (Failing luhn check):

```
curl --location 'http://localhost:8080/payment_methods' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_ekfmmqSYoY0bvjOGGO0Pe5CQdP9oS0ycImpRG78tjNTrgp03HGxvDAhHCrmHrYoO' \
--data '{
  "payment_method": "card",
  "payment_method_type": "credit",
  "payment_method_issuer": "Visa",
  "card": {
    "card_number": "411111111111112",
    "card_exp_month": "10",
    "card_exp_year": "26",
    "card_holder_name": "Chethan"
  },
  "customer_id": "cus_Cbszk87dvhH7EuBp2ZoB",
  "metadata": {
    "city": "NY",
    "unit": "245"
  }
}'
```

![image](https://github.com/juspay/hyperswitch/assets/70657455/cb34dc2b-56c9-4e35-ad39-df6815903fa7)


## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [x] I added unit tests for my changes where possible
