Bug: [FEATURE] allow data migration at profile level



### Feature Description

The `migrate_payment_methods` API is used for migrating customer and payment method data. Currently, it operates at the merchant level, but since the `merchant_connector_id` is tied to a specific profile, migrations are limited to a single profile at a time.

This feature aims to enhance the data migration process to support multiple profiles. The goal is to allow a single payment method to be migrated across several `merchant_connector_id`s, enabling data migration at the profile level.

### Implementation Plan

Below are the the changes:

**Customer Data Migration:**

*   Update `PaymentMethodRecord` to allow `merchant_connector_id` and `connector_customer_id` to accept comma-separated strings.
*   Update `PaymentMethodCustomerMigrate` to handle a list of connector customer details.
*   The `From` implementation for `PaymentMethodCustomerMigrate` will be updated to parse these new comma-separated strings.
*   Finally, update the `create_customer` function to handle the list of connector details, allowing a customer to be associated with multiple connector accounts.

**Payment Method Migration:**

*   Update `PaymentMethodsMigrateForm` to accept a comma-separated string for `merchant_connector_id`.
*   The functions `get_payment_method_records` and `migrate_payment_methods` will be updated to handle this change.
*   The `TryFrom` implementation for `PaymentMethodMigrate` will be modified to create a `PaymentsMandateReference` for each `merchant_connector_id` in the comma-separated string.

All changes will be backward-compatible.

### Have you spent some time checking if this feature request has been raised before?

- [x] I checked and didn't find a similar issue

### Have you read the Contributing Guidelines?

- [x] I have read the [Contributing Guidelines](https://github.com/juspay/hyperswitch/blob/main/docs/CONTRIBUTING.md)

### Are you willing to submit a PR?

Yes, I am willing to submit a PR!

diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs
index c681cf7edd0..b3e76e37291 100644
--- a/crates/api_models/src/payment_methods.rs
+++ b/crates/api_models/src/payment_methods.rs
@@ -19,7 +19,7 @@ use utoipa::{schema, ToSchema};
 #[cfg(feature = "payouts")]
 use crate::payouts;
 use crate::{
-    admin, customers, enums as api_enums,
+    admin, enums as api_enums,
     payments::{self, BankCodeResponse},
 };
 
@@ -2517,6 +2517,7 @@ pub struct PaymentMethodRecord {
     pub billing_address_line3: Option<masking::Secret<String>>,
     pub raw_card_number: Option<masking::Secret<String>>,
     pub merchant_connector_id: Option<id_type::MerchantConnectorAccountId>,
+    pub merchant_connector_ids: Option<String>,
     pub original_transaction_amount: Option<i64>,
     pub original_transaction_currency: Option<common_enums::Currency>,
     pub line_number: Option<i64>,
@@ -2526,18 +2527,6 @@ pub struct PaymentMethodRecord {
     pub network_token_requestor_ref_id: Option<String>,
 }
 
-#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
-pub struct ConnectorCustomerDetails {
-    pub connector_customer_id: String,
-    pub merchant_connector_id: id_type::MerchantConnectorAccountId,
-}
-
-#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
-pub struct PaymentMethodCustomerMigrate {
-    pub customer: customers::CustomerRequest,
-    pub connector_customer_details: Option<ConnectorCustomerDetails>,
-}
-
 #[derive(Debug, Default, serde::Serialize)]
 pub struct PaymentMethodMigrationResponse {
     pub line_number: Option<i64>,
@@ -2654,47 +2643,56 @@ impl From<PaymentMethodMigrationResponseType> for PaymentMethodMigrationResponse
 
 impl
     TryFrom<(
-        PaymentMethodRecord,
+        &PaymentMethodRecord,
         id_type::MerchantId,
-        Option<id_type::MerchantConnectorAccountId>,
+        Option<&Vec<id_type::MerchantConnectorAccountId>>,
     )> for PaymentMethodMigrate
 {
     type Error = error_stack::Report<errors::ValidationError>;
+
     fn try_from(
         item: (
-            PaymentMethodRecord,
+            &PaymentMethodRecord,
             id_type::MerchantId,
-            Option<id_type::MerchantConnectorAccountId>,
+            Option<&Vec<id_type::MerchantConnectorAccountId>>,
         ),
     ) -> Result<Self, Self::Error> {
-        let (record, merchant_id, mca_id) = item;
+        let (record, merchant_id, mca_ids) = item;
         let billing = record.create_billing();
-
-        //  if payment instrument id is present then only construct this
-        let connector_mandate_details = if record.payment_instrument_id.is_some() {
-            Some(PaymentsMandateReference(HashMap::from([(
-                mca_id.get_required_value("merchant_connector_id")?,
-                PaymentsMandateReferenceRecord {
-                    connector_mandate_id: record
-                        .payment_instrument_id
-                        .get_required_value("payment_instrument_id")?
-                        .peek()
-                        .to_string(),
-                    payment_method_type: record.payment_method_type,
-                    original_payment_authorized_amount: record.original_transaction_amount,
-                    original_payment_authorized_currency: record.original_transaction_currency,
-                },
-            )])))
+        let connector_mandate_details = if let Some(payment_instrument_id) =
+            &record.payment_instrument_id
+        {
+            let ids = mca_ids.get_required_value("mca_ids")?;
+            let mandate_map: HashMap<_, _> = ids
+                .iter()
+                .map(|mca_id| {
+                    (
+                        mca_id.clone(),
+                        PaymentsMandateReferenceRecord {
+                            connector_mandate_id: payment_instrument_id.peek().to_string(),
+                            payment_method_type: record.payment_method_type,
+                            original_payment_authorized_amount: record.original_transaction_amount,
+                            original_payment_authorized_currency: record
+                                .original_transaction_currency,
+                        },
+                    )
+                })
+                .collect();
+            Some(PaymentsMandateReference(mandate_map))
         } else {
             None
         };
+
         Ok(Self {
             merchant_id,
-            customer_id: Some(record.customer_id),
+            customer_id: Some(record.customer_id.clone()),
             card: Some(MigrateCardDetail {
-                card_number: record.raw_card_number.unwrap_or(record.card_number_masked),
-                card_exp_month: record.card_expiry_month,
-                card_exp_year: record.card_expiry_year,
+                card_number: record
+                    .raw_card_number
+                    .clone()
+                    .unwrap_or_else(|| record.card_number_masked.clone()),
+                card_exp_month: record.card_expiry_month.clone(),
+                card_exp_year: record.card_expiry_year.clone(),
                 card_holder_name: record.name.clone(),
                 card_network: None,
                 card_type: None,
@@ -2704,10 +2702,16 @@ impl
             }),
             network_token: Some(MigrateNetworkTokenDetail {
                 network_token_data: MigrateNetworkTokenData {
-                    network_token_number: record.network_token_number.unwrap_or_default(),
-                    network_token_exp_month: record.network_token_expiry_month.unwrap_or_default(),
-                    network_token_exp_year: record.network_token_expiry_year.unwrap_or_default(),
-                    card_holder_name: record.name,
+                    network_token_number: record.network_token_number.clone().unwrap_or_default(),
+                    network_token_exp_month: record
+                        .network_token_expiry_month
+                        .clone()
+                        .unwrap_or_default(),
+                    network_token_exp_year: record
+                        .network_token_expiry_year
+                        .clone()
+                        .unwrap_or_default(),
+                    card_holder_name: record.name.clone(),
                     nick_name: record.nick_name.clone(),
                     card_issuing_country: None,
                     card_network: None,
@@ -2716,6 +2720,7 @@ impl
                 },
                 network_token_requestor_ref_id: record
                     .network_token_requestor_ref_id
+                    .clone()
                     .unwrap_or_default(),
             }),
             payment_method: record.payment_method,
@@ -2740,45 +2745,6 @@ impl
     }
 }
 
-#[cfg(feature = "v1")]
-impl From<(PaymentMethodRecord, id_type::MerchantId)> for PaymentMethodCustomerMigrate {
-    fn from(value: (PaymentMethodRecord, id_type::MerchantId)) -> Self {
-        let (record, merchant_id) = value;
-        Self {
-            customer: customers::CustomerRequest {
-                customer_id: Some(record.customer_id),
-                merchant_id,
-                name: record.name,
-                email: record.email,
-                phone: record.phone,
-                description: None,
-                phone_country_code: record.phone_country_code,
-                address: Some(payments::AddressDetails {
-                    city: record.billing_address_city,
-                    country: record.billing_address_country,
-                    line1: record.billing_address_line1,
-                    line2: record.billing_address_line2,
-                    state: record.billing_address_state,
-                    line3: record.billing_address_line3,
-                    zip: record.billing_address_zip,
-                    first_name: record.billing_address_first_name,
-                    last_name: record.billing_address_last_name,
-                }),
-                metadata: None,
-            },
-            connector_customer_details: record
-                .connector_customer_id
-                .zip(record.merchant_connector_id)
-                .map(
-                    |(connector_customer_id, merchant_connector_id)| ConnectorCustomerDetails {
-                        connector_customer_id,
-                        merchant_connector_id,
-                    },
-                ),
-        }
-    }
-}
-
 #[derive(Debug, serde::Deserialize, serde::Serialize, ToSchema)]
 pub struct CardNetworkTokenizeRequest {
     /// Merchant ID associated with the tokenization request
diff --git a/crates/hyperswitch_domain_models/src/lib.rs b/crates/hyperswitch_domain_models/src/lib.rs
index 4ee8620e35e..35570036c20 100644
--- a/crates/hyperswitch_domain_models/src/lib.rs
+++ b/crates/hyperswitch_domain_models/src/lib.rs
@@ -36,6 +36,7 @@ pub mod router_response_types;
 pub mod routing;
 #[cfg(feature = "tokenization_v2")]
 pub mod tokenization;
+pub mod transformers;
 pub mod type_encryption;
 pub mod types;
 pub mod vault;
diff --git a/crates/hyperswitch_domain_models/src/payment_methods.rs b/crates/hyperswitch_domain_models/src/payment_methods.rs
index 0f6d4d6f924..aa01465408e 100644
--- a/crates/hyperswitch_domain_models/src/payment_methods.rs
+++ b/crates/hyperswitch_domain_models/src/payment_methods.rs
@@ -1,5 +1,6 @@
 #[cfg(feature = "v2")]
 use api_models::payment_methods::PaymentMethodsData;
+use api_models::{customers, payment_methods, payments};
 // specific imports because of using the macro
 use common_enums::enums::MerchantStorageScheme;
 #[cfg(feature = "v1")]
@@ -27,11 +28,12 @@ use time::PrimitiveDateTime;
 #[cfg(feature = "v2")]
 use crate::address::Address;
 #[cfg(feature = "v1")]
-use crate::{mandates, type_encryption::AsyncLift};
+use crate::type_encryption::AsyncLift;
 use crate::{
-    mandates::CommonMandateReference,
+    mandates::{self, CommonMandateReference},
     merchant_key_store::MerchantKeyStore,
     payment_method_data as domain_payment_method_data,
+    transformers::ForeignTryFrom,
     type_encryption::{crypto_operation, CryptoOperation},
 };
 
@@ -87,7 +89,6 @@ pub struct PaymentMethod {
     pub network_token_locker_id: Option<String>,
     pub network_token_payment_method_data: OptionalEncryptableValue,
 }
-
 #[cfg(feature = "v2")]
 #[derive(Clone, Debug, router_derive::ToEncryption)]
 pub struct PaymentMethod {
@@ -915,6 +916,136 @@ pub struct PaymentMethodsSessionUpdateInternal {
     pub tokenization_data: Option<pii::SecretSerdeValue>,
 }
 
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+pub struct ConnectorCustomerDetails {
+    pub connector_customer_id: String,
+    pub merchant_connector_id: id_type::MerchantConnectorAccountId,
+}
+
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+pub struct PaymentMethodCustomerMigrate {
+    pub customer: customers::CustomerRequest,
+    pub connector_customer_details: Option<Vec<ConnectorCustomerDetails>>,
+}
+
+#[cfg(feature = "v1")]
+impl TryFrom<(payment_methods::PaymentMethodRecord, id_type::MerchantId)>
+    for PaymentMethodCustomerMigrate
+{
+    type Error = error_stack::Report<ValidationError>;
+    fn try_from(
+        value: (payment_methods::PaymentMethodRecord, id_type::MerchantId),
+    ) -> Result<Self, Self::Error> {
+        let (record, merchant_id) = value;
+        let connector_customer_details = record
+            .connector_customer_id
+            .and_then(|connector_customer_id| {
+                // Handle single merchant_connector_id
+                record
+                    .merchant_connector_id
+                    .as_ref()
+                    .map(|merchant_connector_id| {
+                        Ok(vec![ConnectorCustomerDetails {
+                            connector_customer_id: connector_customer_id.clone(),
+                            merchant_connector_id: merchant_connector_id.clone(),
+                        }])
+                    })
+                    // Handle comma-separated merchant_connector_ids
+                    .or_else(|| {
+                        record
+                            .merchant_connector_ids
+                            .as_ref()
+                            .map(|merchant_connector_ids_str| {
+                                merchant_connector_ids_str
+                                    .split(',')
+                                    .map(|id| id.trim())
+                                    .filter(|id| !id.is_empty())
+                                    .map(|merchant_connector_id| {
+                                        id_type::MerchantConnectorAccountId::wrap(
+                                            merchant_connector_id.to_string(),
+                                        )
+                                        .map_err(|_| {
+                                            error_stack::report!(ValidationError::InvalidValue {
+                                                message: format!(
+                                                    "Invalid merchant_connector_account_id: {}",
+                                                    merchant_connector_id
+                                                ),
+                                            })
+                                        })
+                                        .map(
+                                            |merchant_connector_id| ConnectorCustomerDetails {
+                                                connector_customer_id: connector_customer_id
+                                                    .clone(),
+                                                merchant_connector_id,
+                                            },
+                                        )
+                                    })
+                                    .collect::<Result<Vec<_>, _>>()
+                            })
+                    })
+            })
+            .transpose()?;
+
+        Ok(Self {
+            customer: customers::CustomerRequest {
+                customer_id: Some(record.customer_id),
+                merchant_id,
+                name: record.name,
+                email: record.email,
+                phone: record.phone,
+                description: None,
+                phone_country_code: record.phone_country_code,
+                address: Some(payments::AddressDetails {
+                    city: record.billing_address_city,
+                    country: record.billing_address_country,
+                    line1: record.billing_address_line1,
+                    line2: record.billing_address_line2,
+                    state: record.billing_address_state,
+                    line3: record.billing_address_line3,
+                    zip: record.billing_address_zip,
+                    first_name: record.billing_address_first_name,
+                    last_name: record.billing_address_last_name,
+                }),
+                metadata: None,
+            },
+            connector_customer_details,
+        })
+    }
+}
+
+#[cfg(feature = "v1")]
+impl ForeignTryFrom<(&[payment_methods::PaymentMethodRecord], id_type::MerchantId)>
+    for Vec<PaymentMethodCustomerMigrate>
+{
+    type Error = error_stack::Report<ValidationError>;
+
+    fn foreign_try_from(
+        (records, merchant_id): (&[payment_methods::PaymentMethodRecord], id_type::MerchantId),
+    ) -> Result<Self, Self::Error> {
+        let (customers_migration, migration_errors): (Self, Vec<_>) = records
+            .iter()
+            .map(|record| {
+                PaymentMethodCustomerMigrate::try_from((record.clone(), merchant_id.clone()))
+            })
+            .fold((Self::new(), Vec::new()), |mut acc, result| {
+                match result {
+                    Ok(customer) => acc.0.push(customer),
+                    Err(e) => acc.1.push(e.to_string()),
+                }
+                acc
+            });
+
+        migration_errors
+            .is_empty()
+            .then_some(customers_migration)
+            .ok_or_else(|| {
+                error_stack::report!(ValidationError::InvalidValue {
+                    message: migration_errors.join(", "),
+                })
+            })
+    }
+}
+
 #[cfg(feature = "v1")]
 #[cfg(test)]
 mod tests {
diff --git a/crates/hyperswitch_domain_models/src/transformers.rs b/crates/hyperswitch_domain_models/src/transformers.rs
new file mode 100644
index 00000000000..1208d80c323
--- /dev/null
+++ b/crates/hyperswitch_domain_models/src/transformers.rs
@@ -0,0 +1,9 @@
+pub trait ForeignFrom<F> {
+    fn foreign_from(from: F) -> Self;
+}
+
+pub trait ForeignTryFrom<F>: Sized {
+    type Error;
+
+    fn foreign_try_from(from: F) -> Result<Self, Self::Error>;
+}
diff --git a/crates/payment_methods/src/core/migration.rs b/crates/payment_methods/src/core/migration.rs
index 8b2694cf6fe..6b7ce30b4c5 100644
--- a/crates/payment_methods/src/core/migration.rs
+++ b/crates/payment_methods/src/core/migration.rs
@@ -24,20 +24,22 @@ pub async fn migrate_payment_methods(
     payment_methods: Vec<pm_api::PaymentMethodRecord>,
     merchant_id: &common_utils::id_type::MerchantId,
     merchant_context: &merchant_context::MerchantContext,
-    mca_id: Option<common_utils::id_type::MerchantConnectorAccountId>,
+    mca_ids: Option<Vec<common_utils::id_type::MerchantConnectorAccountId>>,
     controller: &dyn pm::PaymentMethodsController,
 ) -> PmMigrationResult<Vec<pm_api::PaymentMethodMigrationResponse>> {
-    let mut result = Vec::new();
+    let mut result = Vec::with_capacity(payment_methods.len());
+
     for record in payment_methods {
         let req = pm_api::PaymentMethodMigrate::try_from((
-            record.clone(),
+            &record,
             merchant_id.clone(),
-            mca_id.clone(),
+            mca_ids.as_ref(),
         ))
         .map_err(|err| errors::ApiErrorResponse::InvalidRequestData {
             message: format!("error: {:?}", err),
         })
         .attach_printable("record deserialization failed");
+
         let res = match req {
             Ok(migrate_request) => {
                 let res = migrate_payment_method(
@@ -56,6 +58,7 @@ pub async fn migrate_payment_methods(
             }
             Err(e) => Err(e.to_string()),
         };
+
         result.push(pm_api::PaymentMethodMigrationResponse::from((res, record)));
     }
     Ok(api::ApplicationResponse::Json(result))
@@ -69,7 +72,138 @@ pub struct PaymentMethodsMigrateForm {
     pub merchant_id: text::Text<common_utils::id_type::MerchantId>,
 
     pub merchant_connector_id:
-        text::Text<Option<common_utils::id_type::MerchantConnectorAccountId>>,
+        Option<text::Text<common_utils::id_type::MerchantConnectorAccountId>>,
+
+    pub merchant_connector_ids: Option<text::Text<String>>,
+}
+
+struct MerchantConnectorValidator;
+
+impl MerchantConnectorValidator {
+    fn parse_comma_separated_ids(
+        ids_string: &str,
+    ) -> Result<Vec<common_utils::id_type::MerchantConnectorAccountId>, errors::ApiErrorResponse>
+    {
+        // Estimate capacity based on comma count
+        let capacity = ids_string.matches(',').count() + 1;
+        let mut result = Vec::with_capacity(capacity);
+
+        for id in ids_string.split(',') {
+            let trimmed_id = id.trim();
+            if !trimmed_id.is_empty() {
+                let mca_id =
+                    common_utils::id_type::MerchantConnectorAccountId::wrap(trimmed_id.to_string())
+                        .map_err(|_| errors::ApiErrorResponse::InvalidRequestData {
+                            message: format!(
+                                "Invalid merchant_connector_account_id: {}",
+                                trimmed_id
+                            ),
+                        })?;
+                result.push(mca_id);
+            }
+        }
+
+        Ok(result)
+    }
+
+    fn validate_form_csv_conflicts(
+        records: &[pm_api::PaymentMethodRecord],
+        form_has_single_id: bool,
+        form_has_multiple_ids: bool,
+    ) -> Result<(), errors::ApiErrorResponse> {
+        if form_has_single_id {
+            // If form has merchant_connector_id, CSV records should not have merchant_connector_ids
+            for (index, record) in records.iter().enumerate() {
+                if record.merchant_connector_ids.is_some() {
+                    return Err(errors::ApiErrorResponse::InvalidRequestData {
+                        message: format!(
+                            "Record at line {} has merchant_connector_ids but form has merchant_connector_id. Only one should be provided",
+                            index + 1
+                        ),
+                    });
+                }
+            }
+        }
+
+        if form_has_multiple_ids {
+            // If form has merchant_connector_ids, CSV records should not have merchant_connector_id
+            for (index, record) in records.iter().enumerate() {
+                if record.merchant_connector_id.is_some() {
+                    return Err(errors::ApiErrorResponse::InvalidRequestData {
+                        message: format!(
+                            "Record at line {} has merchant_connector_id but form has merchant_connector_ids. Only one should be provided",
+                            index + 1
+                        ),
+                    });
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+type MigrationValidationResult = Result<
+    (
+        common_utils::id_type::MerchantId,
+        Vec<pm_api::PaymentMethodRecord>,
+        Option<Vec<common_utils::id_type::MerchantConnectorAccountId>>,
+    ),
+    errors::ApiErrorResponse,
+>;
+
+impl PaymentMethodsMigrateForm {
+    pub fn validate_and_get_payment_method_records(self) -> MigrationValidationResult {
+        // Step 1: Validate form-level conflicts
+        let form_has_single_id = self.merchant_connector_id.is_some();
+        let form_has_multiple_ids = self.merchant_connector_ids.is_some();
+
+        if form_has_single_id && form_has_multiple_ids {
+            return Err(errors::ApiErrorResponse::InvalidRequestData {
+                message: "Both merchant_connector_id and merchant_connector_ids cannot be provided"
+                    .to_string(),
+            });
+        }
+
+        // Ensure at least one is provided
+        if !form_has_single_id && !form_has_multiple_ids {
+            return Err(errors::ApiErrorResponse::InvalidRequestData {
+                message: "Either merchant_connector_id or merchant_connector_ids must be provided"
+                    .to_string(),
+            });
+        }
+
+        // Step 2: Parse CSV
+        let records = parse_csv(self.file.data.to_bytes()).map_err(|e| {
+            errors::ApiErrorResponse::PreconditionFailed {
+                message: e.to_string(),
+            }
+        })?;
+
+        // Step 3: Validate CSV vs Form conflicts
+        MerchantConnectorValidator::validate_form_csv_conflicts(
+            &records,
+            form_has_single_id,
+            form_has_multiple_ids,
+        )?;
+
+        // Step 4: Prepare the merchant connector account IDs for return
+        let mca_ids = if let Some(ref single_id) = self.merchant_connector_id {
+            Some(vec![(**single_id).clone()])
+        } else if let Some(ref ids_string) = self.merchant_connector_ids {
+            let parsed_ids = MerchantConnectorValidator::parse_comma_separated_ids(ids_string)?;
+            if parsed_ids.is_empty() {
+                None
+            } else {
+                Some(parsed_ids)
+            }
+        } else {
+            None
+        };
+
+        // Step 5: Return the updated structure
+        Ok((self.merchant_id.clone(), records, mca_ids))
+    }
 }
 
 fn parse_csv(data: &[u8]) -> csv::Result<Vec<pm_api::PaymentMethodRecord>> {
@@ -84,27 +218,6 @@ fn parse_csv(data: &[u8]) -> csv::Result<Vec<pm_api::PaymentMethodRecord>> {
     }
     Ok(records)
 }
-pub fn get_payment_method_records(
-    form: PaymentMethodsMigrateForm,
-) -> Result<
-    (
-        common_utils::id_type::MerchantId,
-        Vec<pm_api::PaymentMethodRecord>,
-        Option<common_utils::id_type::MerchantConnectorAccountId>,
-    ),
-    errors::ApiErrorResponse,
-> {
-    match parse_csv(form.file.data.to_bytes()) {
-        Ok(records) => {
-            let merchant_id = form.merchant_id.clone();
-            let mca_id = form.merchant_connector_id.clone();
-            Ok((merchant_id.clone(), records, mca_id))
-        }
-        Err(e) => Err(errors::ApiErrorResponse::PreconditionFailed {
-            message: e.to_string(),
-        }),
-    }
-}
 
 #[instrument(skip_all)]
 pub fn validate_card_expiry(
diff --git a/crates/router/src/core/customers.rs b/crates/router/src/core/customers.rs
index 2cad1d91eb8..43dbf28c0e0 100644
--- a/crates/router/src/core/customers.rs
+++ b/crates/router/src/core/customers.rs
@@ -9,6 +9,7 @@ use common_utils::{
     },
 };
 use error_stack::{report, ResultExt};
+use hyperswitch_domain_models::payment_methods as payment_methods_domain;
 use masking::{ExposeInterface, Secret, SwitchStrategy};
 use payment_methods::controller::PaymentMethodsController;
 use router_env::{instrument, tracing};
@@ -27,7 +28,7 @@ use crate::{
     routes::{metrics, SessionState},
     services,
     types::{
-        api::{customers, payment_methods as payment_methods_api},
+        api::customers,
         domain::{self, types},
         storage::{self, enums},
         transformers::ForeignFrom,
@@ -41,7 +42,7 @@ pub async fn create_customer(
     state: SessionState,
     merchant_context: domain::MerchantContext,
     customer_data: customers::CustomerRequest,
-    connector_customer_details: Option<payment_methods_api::ConnectorCustomerDetails>,
+    connector_customer_details: Option<Vec<payment_methods_domain::ConnectorCustomerDetails>>,
 ) -> errors::CustomerResponse<customers::CustomerResponse> {
     let db: &dyn StorageInterface = state.store.as_ref();
     let key_manager_state = &(&state).into();
@@ -96,7 +97,9 @@ pub async fn create_customer(
 trait CustomerCreateBridge {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         db: &'a dyn StorageInterface,
         merchant_reference_id: &'a Option<id_type::CustomerId>,
         merchant_context: &'a domain::MerchantContext,
@@ -115,7 +118,9 @@ trait CustomerCreateBridge {
 impl CustomerCreateBridge for customers::CustomerRequest {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         db: &'a dyn StorageInterface,
         merchant_reference_id: &'a Option<id_type::CustomerId>,
         merchant_context: &'a domain::MerchantContext,
@@ -175,13 +180,15 @@ impl CustomerCreateBridge for customers::CustomerRequest {
             domain::FromRequestEncryptableCustomer::from_encryptable(encrypted_data)
                 .change_context(errors::CustomersErrorResponse::InternalServerError)?;
 
-        let connector_customer = connector_customer_details.as_ref().map(|details| {
-            let merchant_connector_id = details.merchant_connector_id.get_string_repr().to_string();
-            let connector_customer_id = details.connector_customer_id.to_string();
-            let object = serde_json::json!({
-                merchant_connector_id: connector_customer_id
-            });
-            pii::SecretSerdeValue::new(object)
+        let connector_customer = connector_customer_details.as_ref().map(|details_vec| {
+            let mut map = serde_json::Map::new();
+            for details in details_vec {
+                let merchant_connector_id =
+                    details.merchant_connector_id.get_string_repr().to_string();
+                let connector_customer_id = details.connector_customer_id.clone();
+                map.insert(merchant_connector_id, connector_customer_id.into());
+            }
+            pii::SecretSerdeValue::new(serde_json::Value::Object(map))
         });
 
         Ok(domain::Customer {
@@ -227,7 +234,9 @@ impl CustomerCreateBridge for customers::CustomerRequest {
 impl CustomerCreateBridge for customers::CustomerRequest {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         _db: &'a dyn StorageInterface,
         merchant_reference_id: &'a Option<id_type::CustomerId>,
         merchant_context: &'a domain::MerchantContext,
@@ -297,12 +306,16 @@ impl CustomerCreateBridge for customers::CustomerRequest {
             domain::FromRequestEncryptableCustomer::from_encryptable(encrypted_data)
                 .change_context(errors::CustomersErrorResponse::InternalServerError)?;
 
-        let connector_customer = connector_customer_details.as_ref().map(|details| {
-            let mut map = std::collections::HashMap::new();
-            map.insert(
-                details.merchant_connector_id.clone(),
-                details.connector_customer_id.to_string(),
-            );
+        let connector_customer = connector_customer_details.as_ref().map(|details_vec| {
+            let map: std::collections::HashMap<_, _> = details_vec
+                .iter()
+                .map(|details| {
+                    (
+                        details.merchant_connector_id.clone(),
+                        details.connector_customer_id.to_string(),
+                    )
+                })
+                .collect();
             common_types::customers::ConnectorCustomerMap::new(map)
         });
 
@@ -1060,7 +1073,9 @@ pub async fn update_customer(
 trait CustomerUpdateBridge {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         db: &'a dyn StorageInterface,
         merchant_context: &'a domain::MerchantContext,
         key_manager_state: &'a KeyManagerState,
@@ -1232,7 +1247,9 @@ impl VerifyIdForUpdateCustomer<'_> {
 impl CustomerUpdateBridge for customers::CustomerUpdateRequest {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        _connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        _connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         db: &'a dyn StorageInterface,
         merchant_context: &'a domain::MerchantContext,
         key_manager_state: &'a KeyManagerState,
@@ -1337,7 +1354,9 @@ impl CustomerUpdateBridge for customers::CustomerUpdateRequest {
 impl CustomerUpdateBridge for customers::CustomerUpdateRequest {
     async fn create_domain_model_from_request<'a>(
         &'a self,
-        connector_customer_details: &'a Option<payment_methods_api::ConnectorCustomerDetails>,
+        connector_customer_details: &'a Option<
+            Vec<payment_methods_domain::ConnectorCustomerDetails>,
+        >,
         db: &'a dyn StorageInterface,
         merchant_context: &'a domain::MerchantContext,
         key_manager_state: &'a KeyManagerState,
@@ -1454,7 +1473,7 @@ impl CustomerUpdateBridge for customers::CustomerUpdateRequest {
 
 pub async fn migrate_customers(
     state: SessionState,
-    customers_migration: Vec<payment_methods_api::PaymentMethodCustomerMigrate>,
+    customers_migration: Vec<payment_methods_domain::PaymentMethodCustomerMigrate>,
     merchant_context: domain::MerchantContext,
 ) -> errors::CustomerResponse<()> {
     for customer_migration in customers_migration {
diff --git a/crates/router/src/routes/payment_methods.rs b/crates/router/src/routes/payment_methods.rs
index 20d0121ebde..31558c2d323 100644
--- a/crates/router/src/routes/payment_methods.rs
+++ b/crates/router/src/routes/payment_methods.rs
@@ -10,6 +10,7 @@ use diesel_models::enums::IntentStatus;
 use error_stack::ResultExt;
 use hyperswitch_domain_models::{
     bulk_tokenization::CardNetworkTokenizeRequest, merchant_key_store::MerchantKeyStore,
+    payment_methods::PaymentMethodCustomerMigrate, transformers::ForeignTryFrom,
 };
 use router_env::{instrument, logger, tracing, Flow};
 
@@ -331,10 +332,10 @@ pub async fn migrate_payment_methods(
     MultipartForm(form): MultipartForm<migration::PaymentMethodsMigrateForm>,
 ) -> HttpResponse {
     let flow = Flow::PaymentMethodsMigrate;
-    let (merchant_id, records, merchant_connector_id) =
-        match migration::get_payment_method_records(form) {
-            Ok((merchant_id, records, merchant_connector_id)) => {
-                (merchant_id, records, merchant_connector_id)
+    let (merchant_id, records, merchant_connector_ids) =
+        match form.validate_and_get_payment_method_records() {
+            Ok((merchant_id, records, merchant_connector_ids)) => {
+                (merchant_id, records, merchant_connector_ids)
             }
             Err(e) => return api::log_and_return_error_response(e.into()),
         };
@@ -345,7 +346,7 @@ pub async fn migrate_payment_methods(
         records,
         |state, _, req, _| {
             let merchant_id = merchant_id.clone();
-            let merchant_connector_id = merchant_connector_id.clone();
+            let merchant_connector_ids = merchant_connector_ids.clone();
             async move {
                 let (key_store, merchant_account) =
                     get_merchant_account(&state, &merchant_id).await?;
@@ -354,20 +355,43 @@ pub async fn migrate_payment_methods(
                     domain::Context(merchant_account.clone(), key_store.clone()),
                 ));
 
-                customers::migrate_customers(
-                    state.clone(),
-                    req.iter()
-                        .map(|e| {
-                            payment_methods::PaymentMethodCustomerMigrate::from((
-                                e.clone(),
-                                merchant_id.clone(),
-                            ))
-                        })
-                        .collect(),
-                    merchant_context.clone(),
-                )
-                .await
-                .change_context(errors::ApiErrorResponse::InternalServerError)?;
+                let mut mca_cache = std::collections::HashMap::new();
+                let customers = Vec::<PaymentMethodCustomerMigrate>::foreign_try_from((
+                    &req,
+                    merchant_id.clone(),
+                ))
+                .map_err(|e| errors::ApiErrorResponse::InvalidRequestData {
+                    message: e.to_string(),
+                })?;
+
+                for record in &customers {
+                    if let Some(connector_customer_details) = &record.connector_customer_details {
+                        for connector_customer in connector_customer_details {
+                            if !mca_cache.contains_key(&connector_customer.merchant_connector_id) {
+                                let mca = state
+                        .store
+                        .find_by_merchant_connector_account_merchant_id_merchant_connector_id(
+                            &(&state).into(),
+                            &merchant_id,
+                            &connector_customer.merchant_connector_id,
+                            merchant_context.get_merchant_key_store(),
+                        )
+                        .await
+                        .to_not_found_response(
+                            errors::ApiErrorResponse::MerchantConnectorAccountNotFound {
+                                id: connector_customer.merchant_connector_id.get_string_repr().to_string(),
+                            },
+                        )?;
+                                mca_cache
+                                    .insert(connector_customer.merchant_connector_id.clone(), mca);
+                            }
+                        }
+                    }
+                }
+
+                customers::migrate_customers(state.clone(), customers, merchant_context.clone())
+                    .await
+                    .change_context(errors::ApiErrorResponse::InternalServerError)?;
                 let controller = cards::PmCards {
                     state: &state,
                     merchant_context: &merchant_context,
@@ -377,7 +401,7 @@ pub async fn migrate_payment_methods(
                     req,
                     &merchant_id,
                     &merchant_context,
-                    merchant_connector_id,
+                    merchant_connector_ids,
                     &controller,
                 ))
                 .await
diff --git a/crates/router/src/types/api/payment_methods.rs b/crates/router/src/types/api/payment_methods.rs
index 0c29b58cbb6..4c094af5acc 100644
--- a/crates/router/src/types/api/payment_methods.rs
+++ b/crates/router/src/types/api/payment_methods.rs
@@ -1,32 +1,30 @@
 #[cfg(feature = "v2")]
 pub use api_models::payment_methods::{
     CardDetail, CardDetailFromLocker, CardDetailsPaymentMethod, CardNetworkTokenizeRequest,
-    CardNetworkTokenizeResponse, CardType, ConnectorCustomerDetails,
-    CustomerPaymentMethodResponseItem, DeleteTokenizeByTokenRequest, GetTokenizePayloadRequest,
-    GetTokenizePayloadResponse, ListCountriesCurrenciesRequest, MigrateCardDetail,
-    NetworkTokenDetailsPaymentMethod, NetworkTokenDetailsResponse, NetworkTokenResponse,
-    PaymentMethodCollectLinkRenderRequest, PaymentMethodCollectLinkRequest, PaymentMethodCreate,
-    PaymentMethodCreateData, PaymentMethodCustomerMigrate, PaymentMethodDeleteResponse,
-    PaymentMethodId, PaymentMethodIntentConfirm, PaymentMethodIntentCreate, PaymentMethodListData,
-    PaymentMethodListRequest, PaymentMethodListResponseForSession, PaymentMethodMigrate,
-    PaymentMethodMigrateResponse, PaymentMethodResponse, PaymentMethodResponseData,
-    PaymentMethodUpdate, PaymentMethodUpdateData, PaymentMethodsData, TokenDataResponse,
-    TokenDetailsResponse, TokenizePayloadEncrypted, TokenizePayloadRequest, TokenizedCardValue1,
-    TokenizedCardValue2, TokenizedWalletValue1, TokenizedWalletValue2,
-    TotalPaymentMethodCountResponse,
+    CardNetworkTokenizeResponse, CardType, CustomerPaymentMethodResponseItem,
+    DeleteTokenizeByTokenRequest, GetTokenizePayloadRequest, GetTokenizePayloadResponse,
+    ListCountriesCurrenciesRequest, MigrateCardDetail, NetworkTokenDetailsPaymentMethod,
+    NetworkTokenDetailsResponse, NetworkTokenResponse, PaymentMethodCollectLinkRenderRequest,
+    PaymentMethodCollectLinkRequest, PaymentMethodCreate, PaymentMethodCreateData,
+    PaymentMethodDeleteResponse, PaymentMethodId, PaymentMethodIntentConfirm,
+    PaymentMethodIntentCreate, PaymentMethodListData, PaymentMethodListRequest,
+    PaymentMethodListResponseForSession, PaymentMethodMigrate, PaymentMethodMigrateResponse,
+    PaymentMethodResponse, PaymentMethodResponseData, PaymentMethodUpdate, PaymentMethodUpdateData,
+    PaymentMethodsData, TokenDataResponse, TokenDetailsResponse, TokenizePayloadEncrypted,
+    TokenizePayloadRequest, TokenizedCardValue1, TokenizedCardValue2, TokenizedWalletValue1,
+    TokenizedWalletValue2, TotalPaymentMethodCountResponse,
 };
 #[cfg(feature = "v1")]
 pub use api_models::payment_methods::{
     CardDetail, CardDetailFromLocker, CardDetailsPaymentMethod, CardNetworkTokenizeRequest,
-    CardNetworkTokenizeResponse, ConnectorCustomerDetails, CustomerPaymentMethod,
-    CustomerPaymentMethodsListResponse, DefaultPaymentMethod, DeleteTokenizeByTokenRequest,
-    GetTokenizePayloadRequest, GetTokenizePayloadResponse, ListCountriesCurrenciesRequest,
-    MigrateCardDetail, PaymentMethodCollectLinkRenderRequest, PaymentMethodCollectLinkRequest,
-    PaymentMethodCreate, PaymentMethodCreateData, PaymentMethodCustomerMigrate,
-    PaymentMethodDeleteResponse, PaymentMethodId, PaymentMethodListRequest,
-    PaymentMethodListResponse, PaymentMethodMigrate, PaymentMethodMigrateResponse,
-    PaymentMethodResponse, PaymentMethodUpdate, PaymentMethodsData, TokenizeCardRequest,
-    TokenizeDataRequest, TokenizePayloadEncrypted, TokenizePayloadRequest,
+    CardNetworkTokenizeResponse, CustomerPaymentMethod, CustomerPaymentMethodsListResponse,
+    DefaultPaymentMethod, DeleteTokenizeByTokenRequest, GetTokenizePayloadRequest,
+    GetTokenizePayloadResponse, ListCountriesCurrenciesRequest, MigrateCardDetail,
+    PaymentMethodCollectLinkRenderRequest, PaymentMethodCollectLinkRequest, PaymentMethodCreate,
+    PaymentMethodCreateData, PaymentMethodDeleteResponse, PaymentMethodId,
+    PaymentMethodListRequest, PaymentMethodListResponse, PaymentMethodMigrate,
+    PaymentMethodMigrateResponse, PaymentMethodResponse, PaymentMethodUpdate, PaymentMethodsData,
+    TokenizeCardRequest, TokenizeDataRequest, TokenizePayloadEncrypted, TokenizePayloadRequest,
     TokenizePaymentMethodRequest, TokenizedCardValue1, TokenizedCardValue2, TokenizedWalletValue1,
     TokenizedWalletValue2,
 };
diff --git a/flake.nix b/flake.nix
index e9bcb936d7b..35fa05b73a7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -31,6 +31,7 @@
             openssl
             pkg-config
             postgresql # for libpq
+            protobuf
           ];
 
           # Minimal packages for running hyperswitch
@@ -40,6 +41,7 @@
 
           # Development packages
           devPackages = base ++ (with pkgs; [
+            cargo-watch
             nixd
             rust-bin.stable.${rustDevVersion}.default
             swagger-cli

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

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

## Description
This pull request enhances the data migration API to support migrating customer and payment method data across multiple business profiles in a single request.

Specifically, the following changes have been made:

1.  **Customer Data Migration:**
    *   Updated `PaymentMethodRecord` to allow `merchant_connector_id` to accept comma-separated string values.
    *   Modified `PaymentMethodCustomerMigrate` to use a `Vec` of connector-specific customer details.
    *   The `TryFrom<PaymentMethodRecord>` implementation for `PaymentMethodCustomerMigrate` has been updated to parse this comma-separated string.

2.  **Payment Method Migration:**
    *   Updated `PaymentMethodsMigrateForm` to accept a comma-separated string for `merchant_connector_id`.
    *   The `get_payment_method_records` and `migrate_payment_methods` functions are updated to process multiple `merchant_connector_id`s.
    *   The `TryFrom` implementation for `PaymentMethodMigrate` is modified to create a `PaymentsMandateReference` for each `merchant_connector_id` provided.

### Additional Changes

- [x] 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
This allows migrating a payment method entity across multiple profiles (as `MerchantConnectorAccountId` is tied to a profile) during batch migration.


## How did you test it?
<details>
    <summary>1. Batch migration for multiple profiles</summary>

cURL

    curl --location --request POST 'http://localhost:8080/payment_methods/migrate-batch' \
        --header 'api-key: test_admin' \
        --form 'merchant_id="merchant_1751020165"' \
        --form 'merchant_connector_ids="mca_lwpasOxD1FtFmG4SVdjX,mca_4tsKLvIinX1uVL8p9mhy"' \
        --form 'file=@"migrate.csv"'

Response

    [{"line_number":1,"payment_method_id":"pm_AO2rTJwFIH0I9yQiZ0zT","payment_method":"card","payment_method_type":"debit","customer_id":"45CC60D1F2283097BB1DE41A2636414445908518D50B8B4CDC57959CD7FB4539","migration_status":"Success","card_number_masked":"4360000 xxxx 0005","card_migrated":null,"network_token_migrated":true,"connector_mandate_details_migrated":true,"network_transaction_id_migrated":null},{"line_number":2,"payment_method_id":"pm_ZN9z4oKF439uxyBerwfL","payment_method":"card","payment_method_type":"credit","customer_id":"782BC898951D910C1A99DA69A4E4FAD6CEE53AB9311FA2C552028DA89428DB7F","migration_status":"Success","card_number_masked":"4111111 xxxx 1111","card_migrated":null,"network_token_migrated":true,"connector_mandate_details_migrated":true,"network_transaction_id_migrated":null}]

Things to verify in DB
1. `connector_mandate_details` for a given payment_method_id in response has connector_mandate_id for multiple MCAs
<img width="421" alt="Screenshot 2025-06-28 at 3 09 18 PM" src="https://github.com/user-attachments/assets/8cfe8449-5ed1-441e-bd77-26540d5b6531" />

2. `connector_customer` for a given customer_id in response has mappings for multiple MCAs
<img width="748" alt="Screenshot 2025-06-28 at 3 09 41 PM" src="https://github.com/user-attachments/assets/dec23184-8014-4d8c-91b4-369ec6670e22" />


</details>

<details>
    <summary>2. Batch migration for a single profile - For ensuring backwards compatibility</summary>

cURL

    curl --location --request POST 'http://localhost:8080/payment_methods/migrate-batch' \
        --header 'api-key: test_admin' \
        --form 'merchant_id="merchant_1751020165"' \
        --form 'merchant_connector_id="mca_lwpasOxD1FtFmG4SVdjX"' \
        --form 'file=@"migrate.csv"'

Response

    [{"line_number":1,"payment_method_id":"pm_ZGPPsRrU5f4IgVpMS9tj","payment_method":"card","payment_method_type":"debit","customer_id":"45CC60D1F2283097BB1DE41A2636414445908518D50B8B4CDC57959CD7FB4539","migration_status":"Success","card_number_masked":"4360000 xxxx 0005","card_migrated":null,"network_token_migrated":true,"connector_mandate_details_migrated":true,"network_transaction_id_migrated":null},{"line_number":2,"payment_method_id":"pm_Lf2vcheQkYIiZx8PIDHt","payment_method":"card","payment_method_type":"credit","customer_id":"782BC898951D910C1A99DA69A4E4FAD6CEE53AB9311FA2C552028DA89428DB7F","migration_status":"Success","card_number_masked":"4111111 xxxx 1111","card_migrated":null,"network_token_migrated":true,"connector_mandate_details_migrated":true,"network_transaction_id_migrated":null}]

Things to verify in DB
- PM migration happens for a single MCA (connector_mandate_details)
- Customer migration happens for a single MCA (connector_customer)

</details>

Sample migration data for multi profile migration
[multi-migrate-sample.csv](https://github.com/user-attachments/files/20974125/multi-migrate-sample.csv)


## 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