Bug: Last used merchant account context



To enhance the user experience, especially during sign-up or when switching between multiple merchant accounts, we can store the last used merchant account context directly in the users table. This allows the system to automatically restore the previously active context when a user logs in or returns to the dashboard, rather than requiring them to manually select a merchant account each time.
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index 4f681cda25b..649b8820859 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -41,3 +41,6 @@ pub const REDIS_SSO_TTL: i64 = 5 * 60; // 5 minutes
 pub const DEFAULT_PROFILE_NAME: &str = "default";
 pub const DEFAULT_PRODUCT_TYPE: common_enums::MerchantProductType =
     common_enums::MerchantProductType::Orchestration;
+
+pub const LINEAGE_CONTEXT_TIME_EXPIRY_IN_SECS: i64 = 60 * 60 * 24 * 7; // 7 days
+pub const LINEAGE_CONTEXT_PREFIX: &str = "LINEAGE_CONTEXT_";
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 91b20621da8..6e5360d8848 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -3169,6 +3169,23 @@ pub async fn switch_org_for_user(
         }
     };
 
+    let lineage_context = domain::LineageContext {
+        user_id: user_from_token.user_id.clone(),
+        merchant_id: merchant_id.clone(),
+        role_id: role_id.clone(),
+        org_id: request.org_id.clone(),
+        profile_id: profile_id.clone(),
+        tenant_id: user_from_token
+            .tenant_id
+            .as_ref()
+            .unwrap_or(&state.tenant.tenant_id)
+            .clone(),
+    };
+
+    lineage_context
+        .try_set_lineage_context_in_cache(&state, user_from_token.user_id.as_str())
+        .await;
+
     let token = utils::user::generate_jwt_auth_token_with_attributes(
         &state,
         user_from_token.user_id,
@@ -3364,6 +3381,23 @@ pub async fn switch_merchant_for_user_in_org(
         }
     };
 
+    let lineage_context = domain::LineageContext {
+        user_id: user_from_token.user_id.clone(),
+        merchant_id: merchant_id.clone(),
+        role_id: role_id.clone(),
+        org_id: org_id.clone(),
+        profile_id: profile_id.clone(),
+        tenant_id: user_from_token
+            .tenant_id
+            .as_ref()
+            .unwrap_or(&state.tenant.tenant_id)
+            .clone(),
+    };
+
+    lineage_context
+        .try_set_lineage_context_in_cache(&state, user_from_token.user_id.as_str())
+        .await;
+
     let token = utils::user::generate_jwt_auth_token_with_attributes(
         &state,
         user_from_token.user_id,
@@ -3480,6 +3514,23 @@ pub async fn switch_profile_for_user_in_org_and_merchant(
         }
     };
 
+    let lineage_context = domain::LineageContext {
+        user_id: user_from_token.user_id.clone(),
+        merchant_id: user_from_token.merchant_id.clone(),
+        role_id: role_id.clone(),
+        org_id: user_from_token.org_id.clone(),
+        profile_id: profile_id.clone(),
+        tenant_id: user_from_token
+            .tenant_id
+            .as_ref()
+            .unwrap_or(&state.tenant.tenant_id)
+            .clone(),
+    };
+
+    lineage_context
+        .try_set_lineage_context_in_cache(&state, user_from_token.user_id.as_str())
+        .await;
+
     let token = utils::user::generate_jwt_auth_token_with_attributes(
         &state,
         user_from_token.user_id,
diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs
index 8bf177035ef..016995cb550 100644
--- a/crates/router/src/types/domain/user.rs
+++ b/crates/router/src/types/domain/user.rs
@@ -23,7 +23,7 @@ use hyperswitch_domain_models::api::ApplicationResponse;
 use masking::{ExposeInterface, PeekInterface, Secret};
 use once_cell::sync::Lazy;
 use rand::distributions::{Alphanumeric, DistString};
-use router_env::env;
+use router_env::{env, logger};
 use time::PrimitiveDateTime;
 use unicode_segmentation::UnicodeSegmentation;
 #[cfg(feature = "keymanager_create")]
@@ -39,7 +39,7 @@ use crate::{
     routes::SessionState,
     services::{self, authentication::UserFromToken},
     types::{domain, transformers::ForeignFrom},
-    utils::user::password,
+    utils::{self, user::password},
 };
 
 pub mod dashboard_metadata;
@@ -1458,3 +1458,54 @@ where
             .change_context(UserErrors::InternalServerError)
     }
 }
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct LineageContext {
+    pub user_id: String,
+    pub merchant_id: id_type::MerchantId,
+    pub role_id: String,
+    pub org_id: id_type::OrganizationId,
+    pub profile_id: id_type::ProfileId,
+    pub tenant_id: id_type::TenantId,
+}
+
+impl LineageContext {
+    pub async fn try_get_lineage_context_from_cache(
+        state: &SessionState,
+        user_id: &str,
+    ) -> Option<Self> {
+        // The errors are not handled here because we don't want to fail the request if the cache operation fails.
+        // The errors are logged for debugging purposes.
+        match utils::user::get_lineage_context_from_cache(state, user_id).await {
+            Ok(Some(ctx)) => Some(ctx),
+            Ok(None) => {
+                logger::debug!("Lineage context not found in Redis for user {}", user_id);
+                None
+            }
+            Err(e) => {
+                logger::error!(
+                    "Failed to retrieve lineage context from Redis for user {}: {:?}",
+                    user_id,
+                    e
+                );
+                None
+            }
+        }
+    }
+
+    pub async fn try_set_lineage_context_in_cache(&self, state: &SessionState, user_id: &str) {
+        // The errors are not handled here because we don't want to fail the request if the cache operation fails.
+        // The errors are logged for debugging purposes.
+        if let Err(e) =
+            utils::user::set_lineage_context_in_cache(state, user_id, self.clone()).await
+        {
+            logger::error!(
+                "Failed to set lineage context in Redis for user {}: {:?}",
+                user_id,
+                e
+            );
+        } else {
+            logger::debug!("Lineage context cached for user {}", user_id);
+        }
+    }
+}
diff --git a/crates/router/src/types/domain/user/decision_manager.rs b/crates/router/src/types/domain/user/decision_manager.rs
index bf5556dd9a7..0214d3cdd15 100644
--- a/crates/router/src/types/domain/user/decision_manager.rs
+++ b/crates/router/src/types/domain/user/decision_manager.rs
@@ -1,6 +1,9 @@
 use common_enums::TokenPurpose;
 use common_utils::id_type;
-use diesel_models::{enums::UserStatus, user_role::UserRole};
+use diesel_models::{
+    enums::{UserRoleVersion, UserStatus},
+    user_role::UserRole,
+};
 use error_stack::ResultExt;
 use masking::Secret;
 
@@ -10,6 +13,7 @@ use crate::{
     db::user_role::ListUserRolesByUserIdPayload,
     routes::SessionState,
     services::authentication as auth,
+    types::domain::LineageContext,
     utils,
 };
 
@@ -124,23 +128,93 @@ impl JWTFlow {
         next_flow: &NextFlow,
         user_role: &UserRole,
     ) -> UserResult<Secret<String>> {
+        let user_id = next_flow.user.get_user_id();
+        let cached_lineage_context =
+            LineageContext::try_get_lineage_context_from_cache(state, user_id).await;
+
+        let new_lineage_context = match cached_lineage_context {
+            Some(ctx) => {
+                let tenant_id = ctx.tenant_id.clone();
+                let user_role_match_v1 = state
+                    .global_store
+                    .find_user_role_by_user_id_and_lineage(
+                        &ctx.user_id,
+                        &tenant_id,
+                        &ctx.org_id,
+                        &ctx.merchant_id,
+                        &ctx.profile_id,
+                        UserRoleVersion::V1,
+                    )
+                    .await
+                    .is_ok();
+
+                if user_role_match_v1 {
+                    ctx
+                } else {
+                    let user_role_match_v2 = state
+                        .global_store
+                        .find_user_role_by_user_id_and_lineage(
+                            &ctx.user_id,
+                            &tenant_id,
+                            &ctx.org_id,
+                            &ctx.merchant_id,
+                            &ctx.profile_id,
+                            UserRoleVersion::V2,
+                        )
+                        .await
+                        .is_ok();
+
+                    if user_role_match_v2 {
+                        ctx
+                    } else {
+                        // fallback to default lineage if cached context is invalid
+                        Self::resolve_lineage_from_user_role(state, user_role, user_id).await?
+                    }
+                }
+            }
+            None =>
+            // no cached context found
+            {
+                Self::resolve_lineage_from_user_role(state, user_role, user_id).await?
+            }
+        };
+
+        new_lineage_context
+            .try_set_lineage_context_in_cache(state, user_id)
+            .await;
+
+        auth::AuthToken::new_token(
+            new_lineage_context.user_id,
+            new_lineage_context.merchant_id,
+            new_lineage_context.role_id,
+            &state.conf,
+            new_lineage_context.org_id,
+            new_lineage_context.profile_id,
+            Some(new_lineage_context.tenant_id),
+        )
+        .await
+        .map(|token| token.into())
+    }
+
+    pub async fn resolve_lineage_from_user_role(
+        state: &SessionState,
+        user_role: &UserRole,
+        user_id: &str,
+    ) -> UserResult<LineageContext> {
         let org_id = utils::user_role::get_single_org_id(state, user_role).await?;
         let merchant_id =
             utils::user_role::get_single_merchant_id(state, user_role, &org_id).await?;
         let profile_id =
             utils::user_role::get_single_profile_id(state, user_role, &merchant_id).await?;
 
-        auth::AuthToken::new_token(
-            next_flow.user.get_user_id().to_string(),
-            merchant_id,
-            user_role.role_id.clone(),
-            &state.conf,
+        Ok(LineageContext {
+            user_id: user_id.to_string(),
             org_id,
+            merchant_id,
             profile_id,
-            Some(user_role.tenant_id.clone()),
-        )
-        .await
-        .map(|token| token.into())
+            role_id: user_role.role_id.clone(),
+            tenant_id: user_role.tenant_id.clone(),
+        })
     }
 }
 
diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs
index 89612fc1892..3e339a2a570 100644
--- a/crates/router/src/utils/user.rs
+++ b/crates/router/src/utils/user.rs
@@ -12,7 +12,10 @@ use redis_interface::RedisConnectionPool;
 use router_env::env;
 
 use crate::{
-    consts::user::{REDIS_SSO_PREFIX, REDIS_SSO_TTL},
+    consts::user::{
+        LINEAGE_CONTEXT_PREFIX, LINEAGE_CONTEXT_TIME_EXPIRY_IN_SECS, REDIS_SSO_PREFIX,
+        REDIS_SSO_TTL,
+    },
     core::errors::{StorageError, UserErrors, UserResult},
     routes::SessionState,
     services::{
@@ -20,7 +23,7 @@ use crate::{
         authorization::roles::RoleInfo,
     },
     types::{
-        domain::{self, MerchantAccount, UserFromStorage},
+        domain::{self, LineageContext, MerchantAccount, UserFromStorage},
         transformers::ForeignFrom,
     },
 };
@@ -339,3 +342,49 @@ pub async fn validate_email_domain_auth_type_using_db(
     .then_some(())
     .ok_or(UserErrors::InvalidUserAuthMethodOperation.into())
 }
+
+pub async fn get_lineage_context_from_cache(
+    state: &SessionState,
+    user_id: &str,
+) -> UserResult<Option<LineageContext>> {
+    let connection = get_redis_connection(state)?;
+    let key = format!("{}{}", LINEAGE_CONTEXT_PREFIX, user_id);
+    let lineage_context = connection
+        .get_key::<Option<String>>(&key.into())
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .attach_printable("Failed to get lineage context from redis")?;
+
+    match lineage_context {
+        Some(json_str) => {
+            let ctx = serde_json::from_str::<LineageContext>(&json_str)
+                .change_context(UserErrors::InternalServerError)
+                .attach_printable("Failed to deserialize LineageContext from JSON")?;
+            Ok(Some(ctx))
+        }
+        None => Ok(None),
+    }
+}
+
+pub async fn set_lineage_context_in_cache(
+    state: &SessionState,
+    user_id: &str,
+    lineage_context: LineageContext,
+) -> UserResult<()> {
+    let connection = get_redis_connection(state)?;
+    let key = format!("{}{}", LINEAGE_CONTEXT_PREFIX, user_id);
+    let serialized_lineage_context: String = serde_json::to_string(&lineage_context)
+        .change_context(UserErrors::InternalServerError)
+        .attach_printable("Failed to serialize LineageContext")?;
+    connection
+        .set_key_with_expiry(
+            &key.into(),
+            serialized_lineage_context,
+            LINEAGE_CONTEXT_TIME_EXPIRY_IN_SECS,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)
+        .attach_printable("Failed to set lineage context in redis")?;
+
+    Ok(())
+}


## 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 introduces lineage context caching to enhance login and account-switch flows with better continuity and reduced DB load.

###  Key Changes

- **During Login:**
  - Attempt to fetch the last used `LineageContext` from Redis (`LINEAGE_CONTEXT_<user_id>`).
  - If context is found:
    - Validate whether a matching user role exists (using both `V1` and `V2` role versions).
    - If a valid user_role is found → use this lineage context to issue the JWT token.
    - If not → fallback to the default user_role-based resolution logic.
  - If context is **not found in cache**, fallback to the existing login flow entirely.

- **During Account Switch:**
  - After switching org/merchant/profile context and issuing the new JWT token,
    the new `LineageContext` is saved to Redis (`LINEAGE_CONTEXT_<user_id>`).
  - This allows restoring the last used org/merchant/profile on future logins.

- **Redis Cache Expiry:**
  - Cached lineage context entries are stored with a TTL of **7 days**.
  - If the user is inactive beyond this duration, the system reverts to default logic.

### 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).
-->
- Improves user experience by remembering their last used account context across sessions.
- Reduces unnecessary DB lookups during login for returning users.
- Ensures fallback safety in case of invalid or expired lineage context.

## 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)?
-->
Tested locally with TTL as 1 minute, keys are getting stored in redis
<img width="1065" alt="Screenshot 2025-04-22 at 7 40 32 PM" src="https://github.com/user-attachments/assets/1201a92b-7957-4075-8ec6-9a75376c842a" />

<img width="1369" alt="image" src="https://github.com/user-attachments/assets/2e8fbec0-a1e1-4ef9-b280-67ccbea7b1d1" />

![Screenshot 2025-04-22 at 7 41 43 PM](https://github.com/user-attachments/assets/b0cdf010-0828-4982-98a4-91625d707c84)


### Test 1:
- Log in to the merchant account. 
- Check redis using the key:  ```GET LINEAGE_CONTEXT_<user_id>``` The value should contain the last used lineage context for the user.
- Switch to another org/merchant/profile.
- Recheck redis with the same key: ```GET LINEAGE_CONTEXT_<user_id>```. The lineage should now be changed.

### Test 2:
- After the above test, log out and log in back, within the TTL for the lineage_context, which is 7 days.
- You should be logged in to exactly the same lineage which you last used.

### Test 3 (optional):
- Have a note of the lineage.
- Do not use this account for 7 days.
- Try logging in now.
- You should be logged in to a random account (possibly the lineage which you have not used last)
- This proves, that the key has expired and the default fallback login is now used.



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