Bug: Merchant PML to return only PM API compatible PMs



The `/account/payment_methods` should list only the _Add Payment Method_ flow compatible PMs if invoked with the `PM Client Secret` authentication.

This is to weed out PMs which cannot be saved without making a payment/setting up a recurring payment/mandate. 
For example, to saving wallet PMs like Apple Pay would always require to create a mandate with the connector and hence need not be shown to the customer in the Add Payment Method flow.
diff --git a/config/config.example.toml b/config/config.example.toml
index b2e1fe3c458..b5585a84b32 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -613,3 +613,6 @@ payment_attempts = "hyperswitch-payment-attempt-events"
 payment_intents = "hyperswitch-payment-intent-events"
 refunds = "hyperswitch-refund-events"
 disputes = "hyperswitch-dispute-events"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
\ No newline at end of file
diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml
index 8edfef7485e..fe47b10b543 100644
--- a/config/deployments/integration_test.toml
+++ b/config/deployments/integration_test.toml
@@ -319,3 +319,6 @@ connectors_with_webhook_source_verification_call = "paypal"         # List of co
 
 [unmasked_headers]
 keys = "user-agent"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
\ No newline at end of file
diff --git a/config/deployments/production.toml b/config/deployments/production.toml
index 12724485ea1..3135b3d5682 100644
--- a/config/deployments/production.toml
+++ b/config/deployments/production.toml
@@ -330,3 +330,6 @@ connectors_with_webhook_source_verification_call = "paypal"     # List of connec
 
 [unmasked_headers]
 keys = "user-agent"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
\ No newline at end of file
diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml
index 6c830c389a6..921301ebd6f 100644
--- a/config/deployments/sandbox.toml
+++ b/config/deployments/sandbox.toml
@@ -334,3 +334,6 @@ connectors_with_webhook_source_verification_call = "paypal"        # List of con
 
 [unmasked_headers]
 keys = "user-agent"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
\ No newline at end of file
diff --git a/config/development.toml b/config/development.toml
index 92e13f24fec..db7f1ed93f4 100644
--- a/config/development.toml
+++ b/config/development.toml
@@ -615,3 +615,6 @@ payment_attempts = "hyperswitch-payment-attempt-events"
 payment_intents = "hyperswitch-payment-intent-events"
 refunds = "hyperswitch-refund-events"
 disputes = "hyperswitch-dispute-events"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
diff --git a/config/docker_compose.toml b/config/docker_compose.toml
index d99ab473501..702d134f2d5 100644
--- a/config/docker_compose.toml
+++ b/config/docker_compose.toml
@@ -474,3 +474,6 @@ payment_attempts = "hyperswitch-payment-attempt-events"
 payment_intents = "hyperswitch-payment-intent-events"
 refunds = "hyperswitch-refund-events"
 disputes = "hyperswitch-dispute-events"
+
+[saved_payment_methods]
+sdk_eligible_payment_methods = ["card"]
\ No newline at end of file
diff --git a/crates/diesel_models/src/payment_method.rs b/crates/diesel_models/src/payment_method.rs
index e2807f3b5ea..262848e1d55 100644
--- a/crates/diesel_models/src/payment_method.rs
+++ b/crates/diesel_models/src/payment_method.rs
@@ -143,6 +143,8 @@ pub enum PaymentMethodUpdate {
         status: Option<storage_enums::PaymentMethodStatus>,
         locker_id: Option<String>,
         payment_method: Option<storage_enums::PaymentMethod>,
+        payment_method_type: Option<storage_enums::PaymentMethodType>,
+        payment_method_issuer: Option<String>,
     },
     ConnectorMandateDetailsUpdate {
         connector_mandate_details: Option<serde_json::Value>,
@@ -162,6 +164,8 @@ pub struct PaymentMethodUpdateInternal {
     locker_id: Option<String>,
     payment_method: Option<storage_enums::PaymentMethod>,
     connector_mandate_details: Option<serde_json::Value>,
+    payment_method_type: Option<storage_enums::PaymentMethodType>,
+    payment_method_issuer: Option<String>,
 }
 
 impl PaymentMethodUpdateInternal {
@@ -208,6 +212,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id: None,
                 payment_method: None,
                 connector_mandate_details: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
             PaymentMethodUpdate::PaymentMethodDataUpdate {
                 payment_method_data,
@@ -220,6 +226,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id: None,
                 payment_method: None,
                 connector_mandate_details: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
             PaymentMethodUpdate::LastUsedUpdate { last_used_at } => Self {
                 metadata: None,
@@ -230,6 +238,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id: None,
                 payment_method: None,
                 connector_mandate_details: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
             PaymentMethodUpdate::NetworkTransactionIdAndStatusUpdate {
                 network_transaction_id,
@@ -243,6 +253,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id: None,
                 payment_method: None,
                 connector_mandate_details: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
             PaymentMethodUpdate::StatusUpdate { status } => Self {
                 metadata: None,
@@ -253,12 +265,16 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id: None,
                 payment_method: None,
                 connector_mandate_details: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
             PaymentMethodUpdate::AdditionalDataUpdate {
                 payment_method_data,
                 status,
                 locker_id,
                 payment_method,
+                payment_method_type,
+                payment_method_issuer,
             } => Self {
                 metadata: None,
                 payment_method_data,
@@ -268,6 +284,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 locker_id,
                 payment_method,
                 connector_mandate_details: None,
+                payment_method_issuer,
+                payment_method_type,
             },
             PaymentMethodUpdate::ConnectorMandateDetailsUpdate {
                 connector_mandate_details,
@@ -280,6 +298,8 @@ impl From<PaymentMethodUpdate> for PaymentMethodUpdateInternal {
                 payment_method: None,
                 connector_mandate_details,
                 network_transaction_id: None,
+                payment_method_issuer: None,
+                payment_method_type: None,
             },
         }
     }
diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs
index 537f6666c5d..423f8e03d23 100644
--- a/crates/router/src/configs/secrets_transformers.rs
+++ b/crates/router/src/configs/secrets_transformers.rs
@@ -365,5 +365,6 @@ pub(crate) async fn fetch_raw_secrets(
         connector_onboarding,
         cors: conf.cors,
         unmasked_headers: conf.unmasked_headers,
+        saved_payment_methods: conf.saved_payment_methods,
     }
 }
diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs
index 01b05c60bd9..12ce9c39d6f 100644
--- a/crates/router/src/configs/settings.rs
+++ b/crates/router/src/configs/settings.rs
@@ -119,6 +119,7 @@ pub struct Settings<S: SecretState> {
     #[cfg(feature = "olap")]
     pub connector_onboarding: SecretStateContainer<ConnectorOnboarding, S>,
     pub unmasked_headers: UnmaskedHeaders,
+    pub saved_payment_methods: EligiblePaymentMethods,
 }
 
 #[derive(Debug, Deserialize, Clone, Default)]
@@ -165,6 +166,11 @@ pub struct PaymentMethodAuth {
     pub pm_auth_key: Secret<String>,
 }
 
+#[derive(Debug, Deserialize, Clone, Default)]
+pub struct EligiblePaymentMethods {
+    pub sdk_eligible_payment_methods: HashSet<String>,
+}
+
 #[derive(Debug, Deserialize, Clone, Default)]
 pub struct DefaultExchangeRates {
     pub base_currency: String,
diff --git a/crates/router/src/core/payment_methods/cards.rs b/crates/router/src/core/payment_methods/cards.rs
index 27d126bce90..d3f6aeea24b 100644
--- a/crates/router/src/core/payment_methods/cards.rs
+++ b/crates/router/src/core/payment_methods/cards.rs
@@ -441,6 +441,8 @@ pub async fn add_payment_method_data(
                             status: Some(enums::PaymentMethodStatus::Active),
                             locker_id: Some(locker_id),
                             payment_method: req.payment_method,
+                            payment_method_issuer: req.payment_method_issuer,
+                            payment_method_type: req.payment_method_type,
                         };
 
                         db.update_payment_method(
@@ -555,7 +557,7 @@ pub async fn add_payment_method(
     match duplication_check {
         Some(duplication_check) => match duplication_check {
             payment_methods::DataDuplicationCheck::Duplicated => {
-                get_or_insert_payment_method(
+                let existing_pm = get_or_insert_payment_method(
                     db,
                     req.clone(),
                     &mut resp,
@@ -564,6 +566,8 @@ pub async fn add_payment_method(
                     key_store,
                 )
                 .await?;
+
+                resp.client_secret = existing_pm.client_secret;
             }
             payment_methods::DataDuplicationCheck::MetaDataChanged => {
                 if let Some(card) = req.card.clone() {
@@ -577,6 +581,8 @@ pub async fn add_payment_method(
                     )
                     .await?;
 
+                    let client_secret = existing_pm.client_secret.clone();
+
                     delete_card_from_locker(
                         &state,
                         &customer_id,
@@ -653,6 +659,8 @@ pub async fn add_payment_method(
                     .await
                     .change_context(errors::ApiErrorResponse::InternalServerError)
                     .attach_printable("Failed to add payment method in db")?;
+
+                    resp.client_secret = client_secret;
                 }
             }
         },
@@ -667,7 +675,7 @@ pub async fn add_payment_method(
                 None
             };
             resp.payment_method_id = generate_id(consts::ID_LENGTH, "pm");
-            insert_payment_method(
+            let pm = insert_payment_method(
                 db,
                 &resp,
                 req,
@@ -682,6 +690,8 @@ pub async fn add_payment_method(
                 merchant_account.storage_scheme,
             )
             .await?;
+
+            resp.client_secret = pm.client_secret;
         }
     }
 
@@ -744,6 +754,18 @@ pub async fn update_customer_payment_method(
             .await
             .to_not_found_response(errors::ApiErrorResponse::PaymentMethodNotFound)?;
 
+        if pm.status == enums::PaymentMethodStatus::AwaitingData {
+            return Err(report!(errors::ApiErrorResponse::NotSupported {
+                message: "Payment method is awaiting data so it cannot be updated".into()
+            }));
+        }
+
+        if pm.payment_method_data.is_none() {
+            return Err(report!(errors::ApiErrorResponse::GenericNotFoundError {
+                message: "payment_method_data not found".to_string()
+            }));
+        }
+
         // Fetch the existing payment method data from db
         let existing_card_data = decrypt::<serde_json::Value, masking::WithType>(
             pm.payment_method_data.clone(),
@@ -812,7 +834,7 @@ pub async fn update_customer_payment_method(
                 wallet: req.wallet,
                 metadata: req.metadata,
                 customer_id: Some(pm.customer_id.clone()),
-                client_secret: None,
+                client_secret: pm.client_secret.clone(),
                 payment_method_data: None,
                 card_network: req
                     .card_network
@@ -901,7 +923,7 @@ pub async fn update_customer_payment_method(
                 installment_payment_enabled: false,
                 payment_experience: Some(vec![api_models::enums::PaymentExperience::RedirectToUrl]),
                 last_used_at: Some(common_utils::date_time::now()),
-                client_secret: None,
+                client_secret: pm.client_secret.clone(),
             }
         };
 
@@ -1691,12 +1713,21 @@ pub async fn list_payment_methods(
     let db = &*state.store;
     let pm_config_mapping = &state.conf.pm_filters;
 
-    let payment_intent = helpers::verify_payment_intent_time_and_client_secret(
-        db,
-        &merchant_account,
-        req.client_secret.clone(),
-    )
-    .await?;
+    let payment_intent = if let Some(cs) = &req.client_secret {
+        if cs.starts_with("pm_") {
+            validate_payment_method_and_client_secret(cs, db, &merchant_account).await?;
+            None
+        } else {
+            helpers::verify_payment_intent_time_and_client_secret(
+                db,
+                &merchant_account,
+                req.client_secret.clone(),
+            )
+            .await?
+        }
+    } else {
+        None
+    };
 
     let shipping_address = payment_intent
         .as_ref()
@@ -1839,6 +1870,7 @@ pub async fn list_payment_methods(
             pm_config_mapping,
             &state.conf.mandates.supported_payment_methods,
             &state.conf.mandates.update_mandate_supported,
+            &state.conf.saved_payment_methods,
         )
         .await?;
     }
@@ -2535,6 +2567,34 @@ pub async fn list_payment_methods(
     ))
 }
 
+async fn validate_payment_method_and_client_secret(
+    cs: &String,
+    db: &dyn db::StorageInterface,
+    merchant_account: &domain::MerchantAccount,
+) -> Result<(), error_stack::Report<errors::ApiErrorResponse>> {
+    let pm_vec = cs.split("_secret").collect::<Vec<&str>>();
+    let pm_id = pm_vec
+        .first()
+        .ok_or(errors::ApiErrorResponse::MissingRequiredField {
+            field_name: "client_secret",
+        })?;
+
+    let payment_method = db
+        .find_payment_method(pm_id, merchant_account.storage_scheme)
+        .await
+        .change_context(errors::ApiErrorResponse::PaymentMethodNotFound)
+        .attach_printable("Unable to find payment method")?;
+
+    let client_secret_expired =
+        authenticate_pm_client_secret_and_check_expiry(cs, &payment_method)?;
+    if client_secret_expired {
+        return Err::<(), error_stack::Report<errors::ApiErrorResponse>>(
+            (errors::ApiErrorResponse::ClientSecretExpired).into(),
+        );
+    }
+    Ok(())
+}
+
 pub async fn call_surcharge_decision_management(
     state: routes::AppState,
     merchant_account: &domain::MerchantAccount,
@@ -2644,6 +2704,7 @@ pub async fn filter_payment_methods(
     config: &settings::ConnectorFilters,
     supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate,
     supported_payment_methods_for_update_mandate: &settings::SupportedPaymentMethodsForMandate,
+    saved_payment_methods: &settings::EligiblePaymentMethods,
 ) -> errors::CustomResult<(), errors::ApiErrorResponse> {
     for payment_method in payment_methods.into_iter() {
         let parse_result = serde_json::from_value::<PaymentMethodsEnabled>(payment_method);
@@ -2761,6 +2822,20 @@ pub async fn filter_payment_methods(
                         })
                         .unwrap_or(true);
 
+                    let filter9 = req
+                        .client_secret
+                        .as_ref()
+                        .map(|cs| {
+                            if cs.starts_with("pm_") {
+                                saved_payment_methods
+                                    .sdk_eligible_payment_methods
+                                    .contains(payment_method.to_string().as_str())
+                            } else {
+                                true
+                            }
+                        })
+                        .unwrap_or(true);
+
                     let connector = connector.clone();
 
                     let response_pm_type = ResponsePaymentMethodIntermediate::new(
@@ -2777,6 +2852,7 @@ pub async fn filter_payment_methods(
                         && filter6
                         && filter7
                         && filter8
+                        && filter9
                     {
                         resp.push(response_pm_type);
                     }
diff --git a/crates/router/src/core/payment_methods/transformers.rs b/crates/router/src/core/payment_methods/transformers.rs
index 749508c9740..9c53642f4d7 100644
--- a/crates/router/src/core/payment_methods/transformers.rs
+++ b/crates/router/src/core/payment_methods/transformers.rs
@@ -374,7 +374,7 @@ pub fn mk_add_card_response_hs(
         installment_payment_enabled: false, // #[#256]
         payment_experience: Some(vec![api_models::enums::PaymentExperience::RedirectToUrl]),
         last_used_at: Some(common_utils::date_time::now()), // [#256]
-        client_secret: None,
+        client_secret: req.client_secret,
     }
 }
 

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

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

## Description
<!-- Describe your changes in detail -->
This PR builds upon -  https://github.com/juspay/hyperswitch/pull/4134
After generating a `pm client_secret`, `/ist_payment_methods` can be altered for vaulting purposes.
For saved payment methods, if `pm client_secret` is passed in merchant payment method list, the payment methods will be filtered on a config `saved_payment_methods`.
Only the payment methods listed in the config `saved_payment_methods` would be displayed as a part of "Vaulting eligible payment methods". 
The customer can then select one of the listed payment_methods, after which the SDK will hit the `/save` API for vaulting it.


### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [x] 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. Create a customer
```
curl --location --request POST 'http://localhost:8080/customers' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_y5XDvbHKvRI6K2dAhTgXvNgQykJiqLXeYtSysCQzPGBGz3RZoUPjPcfD1LW8teYO' \
--data-raw '{
    "email": "guest@example.com",
    "name": "John Doe1",
    "phone": "999999999",
    "phone_country_code": "+65",
    "description": "First customer",
    "address": {
        "line1": "1467",
        "line2": "Harrison Street",
        "line3": "Harrison Street",
        "city": "San Fransico",
        "state": "California",
        "zip": "94122",
        "country": "US",
        "first_name": "John",
        "last_name": "Doe"
    },
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}'
```
2. Create a payment method
```
curl --location --request POST 'http://localhost:8080/payment_methods' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: dev_63yd7Y0i2CvRcJYKfXcIE8wAva0b5YMtKHr6oTpcnggVmWqsK0Q6CvoKB1WiXoTz' \
--data-raw '{
    "customer_id": "cus_RHnmMuG8mKhICwigyUeP"
}'
```
Response - 
```
{
    "merchant_id": "sarthak",
    "customer_id": "cus_RHnmMuG8mKhICwigyUeP",
    "payment_method_id": "pm_8b24uUXEoZIBTcUIPRBD",
    "payment_method": null,
    "payment_method_type": null,
    "card": null,
    "recurring_enabled": false,
    "installment_payment_enabled": false,
    "payment_experience": null,
    "metadata": null,
    "created": null,
    "last_used_at": null,
    "client_secret": "pm_8b24uUXEoZIBTcUIPRBD_secret_FyFrpYRzEU0NH0zbQJXC"
}
```
3. Pass pm client secret in merchant pm list - 
```
curl --location --request GET 'http://localhost:8080/account/payment_methods?client_secret=pm_8b24uUXEoZIBTcUIPRBD_secret_FyFrpYRzEU0NH0zbQJXC' \
--header 'Accept: application/json' \
--header 'api-key: pk_dev_ee1bf1a13355408da5f22cbee38ffca4'
```
Response - 
<img width="1512" alt="image" src="https://github.com/juspay/hyperswitch/assets/76486416/da50f13b-9107-4c0f-8efc-24baa1e34d2e">

4. Save the payment method with client_secret
```
curl --location --request POST 'http://localhost:8080/payment_methods/pm_8b24uUXEoZIBTcUIPRBD/save' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: pk_dev_660e47ec672f43849d6b0403fcaf76ac' \
--data-raw '{
    "payment_method": "card",
    "client_secret": "pm_8b24uUXEoZIBTcUIPRBD_secret_FyFrpYRzEU0NH0zbQJXC",
    "customer_id": "cus_RHnmMuG8mKhICwigyUeP",
    "payment_method_data": {
        "card": {
            "card_number": "4242424242424242",
            "card_exp_month": "10",
            "card_exp_year": "2026",
            "card_holder_name": "joseph"
        }
    }
}'
```
Response - 
```
{
    "merchant_id": "sarthak",
    "customer_id": "cus_RHnmMuG8mKhICwigyUeP",
    "payment_method_id": "pm_DC74n7ocq8SKGvNVai0M",
    "payment_method": "card",
    "payment_method_type": null,
    "card": {
        "scheme": null,
        "issuer_country": null,
        "last4_digits": "4242",
        "expiry_month": "10",
        "expiry_year": "2026",
        "card_token": null,
        "card_holder_name": "joseph",
        "card_fingerprint": null,
        "nick_name": null,
        "card_network": null,
        "card_isin": "424242",
        "card_issuer": null,
        "card_type": null,
        "saved_to_locker": true
    },
    "recurring_enabled": false,
    "installment_payment_enabled": false,
    "payment_experience": [
        "redirect_to_url"
    ],
    "metadata": null,
    "created": "2024-04-03T13:02:00.926Z",
    "last_used_at": "2024-04-03T13:02:00.926Z",
    "client_secret": "pm_8b24uUXEoZIBTcUIPRBD_secret_FyFrpYRzEU0NH0zbQJXC"
}
```



## 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`
- [ ] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
- [ ] I added a [CHANGELOG](/CHANGELOG.md) entry if applicable
