Bug: refactor(mandates): add validations for recurring mandates using payment_method_id



When a setup mandate is done with customer, when doing recurring payment using `payment_method_id`, add validation to match on customer_id such that they match on setup mandate as well as recurring.

Also add validation not to pass both `mandate_id` and `recurring_details`
diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs
index f08a793ebf5..78140ea2932 100644
--- a/crates/api_models/src/payment_methods.rs
+++ b/crates/api_models/src/payment_methods.rs
@@ -282,6 +282,28 @@ fn saved_in_locker_default() -> bool {
     true
 }
 
+impl From<CardDetailFromLocker> for payments::AdditionalCardInfo {
+    fn from(item: CardDetailFromLocker) -> Self {
+        Self {
+            card_issuer: item.card_issuer,
+            card_network: item.card_network,
+            card_type: item.card_type,
+            card_issuing_country: item.issuer_country,
+            bank_code: None,
+            last4: item.last4_digits,
+            card_isin: item.card_isin,
+            card_extended_bin: item
+                .card_number
+                .map(|card_number| card_number.get_card_extended_bin()),
+            card_exp_month: item.expiry_month,
+            card_exp_year: item.expiry_year,
+            card_holder_name: item.card_holder_name,
+            payment_checks: None,
+            authentication_data: None,
+        }
+    }
+}
+
 impl From<CardDetailsPaymentMethod> for CardDetailFromLocker {
     fn from(item: CardDetailsPaymentMethod) -> Self {
         Self {
diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs
index 0f80b6a52ea..1116e49a4e5 100644
--- a/crates/router/src/core/payments/helpers.rs
+++ b/crates/router/src/core/payments/helpers.rs
@@ -469,6 +469,18 @@ pub async fn get_token_pm_type_mandate_details(
                                 errors::ApiErrorResponse::PaymentMethodNotFound,
                             )?;
 
+                        let customer_id = request
+                            .customer_id
+                            .clone()
+                            .get_required_value("customer_id")?;
+
+                        verify_mandate_details_for_recurring_payments(
+                            &payment_method_info.merchant_id,
+                            &merchant_account.merchant_id,
+                            &payment_method_info.customer_id,
+                            &customer_id,
+                        )?;
+
                         (
                             None,
                             Some(payment_method_info.payment_method),
@@ -869,6 +881,7 @@ pub fn validate_mandate(
 pub fn validate_recurring_details_and_token(
     recurring_details: &Option<RecurringDetails>,
     payment_token: &Option<String>,
+    mandate_id: &Option<String>,
 ) -> CustomResult<(), errors::ApiErrorResponse> {
     utils::when(
         recurring_details.is_some() && payment_token.is_some(),
@@ -880,6 +893,12 @@ pub fn validate_recurring_details_and_token(
         },
     )?;
 
+    utils::when(recurring_details.is_some() && mandate_id.is_some(), || {
+        Err(report!(errors::ApiErrorResponse::PreconditionFailed {
+            message: "Expected one out of recurring_details and mandate_id but got both".into()
+        }))
+    })?;
+
     Ok(())
 }
 
@@ -1080,6 +1099,24 @@ pub fn verify_mandate_details(
     )
 }
 
+pub fn verify_mandate_details_for_recurring_payments(
+    mandate_merchant_id: &str,
+    merchant_id: &str,
+    mandate_customer_id: &str,
+    customer_id: &str,
+) -> RouterResult<()> {
+    if mandate_merchant_id != merchant_id {
+        Err(report!(errors::ApiErrorResponse::MandateNotFound))?
+    }
+    if mandate_customer_id != customer_id {
+        Err(report!(errors::ApiErrorResponse::PreconditionFailed {
+            message: "customer_id must match mandate customer_id".into()
+        }))?
+    }
+
+    Ok(())
+}
+
 #[instrument(skip_all)]
 pub fn payment_attempt_status_fsm(
     payment_method_data: &Option<api::payments::PaymentMethodDataRequest>,
diff --git a/crates/router/src/core/payments/operations/payment_complete_authorize.rs b/crates/router/src/core/payments/operations/payment_complete_authorize.rs
index 9d0971982a6..ccefb3b2b53 100644
--- a/crates/router/src/core/payments/operations/payment_complete_authorize.rs
+++ b/crates/router/src/core/payments/operations/payment_complete_authorize.rs
@@ -461,6 +461,7 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve> ValidateRequest<F, api::Paymen
         helpers::validate_recurring_details_and_token(
             &request.recurring_details,
             &request.payment_token,
+            &request.mandate_id,
         )?;
 
         Ok((
diff --git a/crates/router/src/core/payments/operations/payment_confirm.rs b/crates/router/src/core/payments/operations/payment_confirm.rs
index eca8d2d90f6..4eac9c1f4de 100644
--- a/crates/router/src/core/payments/operations/payment_confirm.rs
+++ b/crates/router/src/core/payments/operations/payment_confirm.rs
@@ -1205,6 +1205,7 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve> ValidateRequest<F, api::Paymen
         helpers::validate_recurring_details_and_token(
             &request.recurring_details,
             &request.payment_token,
+            &request.mandate_id,
         )?;
 
         let payment_id = request
diff --git a/crates/router/src/core/payments/operations/payment_create.rs b/crates/router/src/core/payments/operations/payment_create.rs
index 6dce6db9d9b..80b7d52d31e 100644
--- a/crates/router/src/core/payments/operations/payment_create.rs
+++ b/crates/router/src/core/payments/operations/payment_create.rs
@@ -1,15 +1,17 @@
 use std::marker::PhantomData;
 
-use api_models::{enums::FrmSuggestion, mandates::RecurringDetails};
+use api_models::{
+    enums::FrmSuggestion, mandates::RecurringDetails, payment_methods::PaymentMethodsData,
+};
 use async_trait::async_trait;
 use common_utils::ext_traits::{AsyncExt, Encode, ValueExt};
 use data_models::{
     mandates::{MandateData, MandateDetails},
     payments::payment_attempt::PaymentAttempt,
 };
-use diesel_models::ephemeral_key;
+use diesel_models::{ephemeral_key, PaymentMethod};
 use error_stack::{self, ResultExt};
-use masking::PeekInterface;
+use masking::{ExposeInterface, PeekInterface};
 use router_derive::PaymentOperation;
 use router_env::{instrument, tracing};
 use time::PrimitiveDateTime;
@@ -259,6 +261,8 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve>
             payment_method_billing_address
                 .as_ref()
                 .map(|address| address.address_id.clone()),
+            &payment_method_info,
+            merchant_key_store,
         )
         .await?;
 
@@ -690,6 +694,7 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve> ValidateRequest<F, api::Paymen
         helpers::validate_recurring_details_and_token(
             &request.recurring_details,
             &request.payment_token,
+            &request.mandate_id,
         )?;
 
         if request.confirm.unwrap_or(false) {
@@ -743,6 +748,8 @@ impl PaymentCreate {
         browser_info: Option<serde_json::Value>,
         state: &AppState,
         payment_method_billing_address_id: Option<String>,
+        payment_method_info: &Option<PaymentMethod>,
+        key_store: &domain::MerchantKeyStore,
     ) -> RouterResult<(
         storage::PaymentAttemptNew,
         Option<api_models::payments::AdditionalPaymentData>,
@@ -752,7 +759,7 @@ impl PaymentCreate {
             helpers::payment_attempt_status_fsm(&request.payment_method_data, request.confirm);
         let (amount, currency) = (money.0, Some(money.1));
 
-        let additional_pm_data = request
+        let mut additional_pm_data = request
             .payment_method_data
             .as_ref()
             .async_map(|payment_method_data| async {
@@ -763,6 +770,35 @@ impl PaymentCreate {
                 .await
             })
             .await;
+
+        if additional_pm_data.is_none() {
+            // If recurring payment is made using payment_method_id, then fetch payment_method_data from retrieved payment_method object
+            additional_pm_data = payment_method_info
+                .as_ref()
+                .async_map(|pm_info| async {
+                    domain::types::decrypt::<serde_json::Value, masking::WithType>(
+                        pm_info.payment_method_data.clone(),
+                        key_store.key.get_inner().peek(),
+                    )
+                    .await
+                    .change_context(errors::StorageError::DecryptionError)
+                    .attach_printable("unable to decrypt card details")
+                    .ok()
+                    .flatten()
+                    .map(|x| x.into_inner().expose())
+                    .and_then(|v| serde_json::from_value::<PaymentMethodsData>(v).ok())
+                    .and_then(|pmd| match pmd {
+                        PaymentMethodsData::Card(crd) => Some(api::CardDetailFromLocker::from(crd)),
+                        _ => None,
+                    })
+                })
+                .await
+                .flatten()
+                .map(|card| {
+                    api_models::payments::AdditionalPaymentData::Card(Box::new(card.into()))
+                })
+        };
+
         let additional_pm_data_value = additional_pm_data
             .as_ref()
             .map(Encode::encode_to_value)
@@ -841,7 +877,9 @@ impl PaymentCreate {
                 connector: None,
                 error_message: None,
                 offer_amount: None,
-                payment_method_id: None,
+                payment_method_id: payment_method_info
+                    .as_ref()
+                    .map(|pm_info| pm_info.payment_method_id.clone()),
                 cancellation_reason: None,
                 error_code: None,
                 connector_metadata: None,
diff --git a/crates/router/src/core/payments/operations/payment_update.rs b/crates/router/src/core/payments/operations/payment_update.rs
index a6a41732f1b..76c28307dba 100644
--- a/crates/router/src/core/payments/operations/payment_update.rs
+++ b/crates/router/src/core/payments/operations/payment_update.rs
@@ -754,6 +754,7 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve> ValidateRequest<F, api::Paymen
         helpers::validate_recurring_details_and_token(
             &request.recurring_details,
             &request.payment_token,
+            &request.mandate_id,
         )?;
 
         Ok((

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

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

## Description
<!-- Describe your changes in detail -->
When a setup mandate is done with customer, when doing recurring payment using `payment_method_id`, this PR adds following validation
- to match on customer_id such that they match on setup mandate as well as recurring.
- to match on merchant_id such that they match on setup mandate as well as recurring.
- not to pass both `mandate_id` and `recurring_details` 

This PR also stores `payment_method_id` and `payment_method_data` in payment_attempt table when doing recurring payment with `payment_method_id`


### 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. Setup mandate with a customer

```
curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: abc' \
--data-raw '{
    "amount": 0,
    "currency": "USD",
    "confirm": true,
    "capture_method": "automatic",
    "customer_id": "test_cus4",
    "email": "guest@example.com",
    "setup_future_usage": "off_session",
    "customer_acceptance": {
        "acceptance_type": "online"
    },
    "payment_method": "card",
    "payment_method_type": "credit",
    "payment_method_data": {
        "card": {
            "card_number": "4242424242424242",
            "card_exp_month": "03",
            "card_exp_year": "2030",
            "card_holder_name": "Test Holder",
            "card_cvc": "737"
        }
    },
    "payment_type": "setup_mandate",
    "billing": {
        "address": {
            "city": "test",
            "country": "US",
            "line1": "here",
            "line2": "there",
            "line3": "anywhere",
            "zip": "560095",
            "state": "Washington",
            "first_name": "One",
            "last_name": "Two"
        },
        "phone": {
            "number": "1234567890",
            "country_code": "+1"
        },
        "email": "guest@example.com"
    },
    "authentication_type": "no_three_ds",
    "mandate_data": {
        "customer_acceptance": {
            "acceptance_type": "offline",
            "accepted_at": "1963-05-03T04:07:52.723Z",
            "online": {
                "ip_address": "153.54.53.134",
                "user_agent": "amet irure esse"
            }
        },
        "mandate_type": {
            "multi_use": null
        }
    }
}'
``` 

2. Now try to do recurring payment using`payment_method_id` by passing different customer_id

```
curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: abc' \
--data-raw '{
    "amount": 499,
    "currency": "USD",
    "confirm": true,
    "capture_method": "automatic",
    "customer_id": "test_cus5",
    "email": "guest@example.com",
    "off_session": true,
    "recurring_details": {
        "type": "payment_method_id",
        "data": "xyz"
    },
    "billing": {
        "address": {
            "city": "test",
            "country": "US",
            "line1": "here",
            "line2": "there",
            "line3": "anywhere",
            "zip": "560095",
            "state": "Washington",
            "first_name": "One",
            "last_name": "Two"
        },
        "phone": {
            "number": "1234567890",
            "country_code": "+1"
        },
        "email": "guest@example.com"
    },
    "authentication_type": "no_three_ds"
}'
```

![image](https://github.com/juspay/hyperswitch/assets/70657455/fec93264-f73d-4501-a77d-5afba5283ef9)


3. Now try to pass both `mandate_id` and `recurring_details`

```
curl --location 'http://localhost:8080/payments' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: abc' \
--data-raw '{
    "amount": 499,
    "currency": "USD",
    "confirm": true,
    "capture_method": "automatic",
    "customer_id": "test_cus4",
    "email": "guest@example.com",
    "off_session": true,
    "recurring_details": {
        "type": "payment_method_id",
        "data": "pm_7zf8MTSyG3h6grOBwPPc"
    },
    "mandate_id": "abc",
    "billing": {
        "address": {
            "city": "test",
            "country": "US",
            "line1": "here",
            "line2": "there",
            "line3": "anywhere",
            "zip": "560095",
            "state": "Washington",
            "first_name": "One",
            "last_name": "Two"
        },
        "phone": {
            "number": "1234567890",
            "country_code": "+1"
        },
        "email": "guest@example.com"
    },
    "authentication_type": "no_three_ds"
}'
```

![image](https://github.com/juspay/hyperswitch/assets/70657455/02897684-e7b7-4e1d-ac14-16d27bdd540d)

4. When recurring payment is done using `payment_method_id`, both `payment_method_id` and `payment_method_data` is getting stored in attempt table

![image](https://github.com/juspay/hyperswitch/assets/70657455/4408c274-9b8b-4485-ad7e-65287a46c531)


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