From ff2559df3edd538f447e261662ea99e12837428f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 May 2026 22:45:58 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/routing/utils/DeepLinkFactory.kt | 7 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 15 + .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../common/routing/deeplink/DeeplinkConst.kt | 2 + .../deeplink/PayloadToDeeplinkConverter.kt | 18 + .../PayloadToDeeplinkConverterTest.kt | 87 ++++ .../utils/TangemPayTxHistoryItemConverter.kt | 1 + .../model/TangemPayPushNotificationType.kt | 17 + .../TangemPayTxHistoryItemStatusConverter.kt | 4 +- ...angemPayTransactionBottomSheetComponent.kt | 19 + .../components/TangemPayDetailsComponent.kt | 2 +- .../TangemPayTxHistoryDetailsComponent.kt | 29 +- .../di/TangemPayDetailsFeatureModule.kt | 8 + .../model/TangemPayTxHistoryDetailsModel.kt | 4 +- .../deeplink/TangemPayMainDeepLinkHandler.kt | 10 + .../DefaultTangemPayMainDeepLinkHandler.kt | 118 ++++++ .../tangempay/deeplink/TangemPayPushAction.kt | 20 + ...mPayPushPayloadToTxHistoryItemConverter.kt | 90 ++++ .../tangempay/di/TangemPayDeeplinkModule.kt | 8 + ...PushPayloadToTxHistoryItemConverterTest.kt | 400 ++++++++++++++++++ features/wallet/api/build.gradle.kts | 1 + .../deeplink/SelectWalletInDeepLinkTrigger.kt | 10 +- features/wallet/impl/build.gradle.kts | 1 + .../wallet/child/wallet/WalletComponent.kt | 14 + .../wallet/child/wallet/model/WalletModel.kt | 16 + .../DefaultWalletDeepLinkActionTrigger.kt | 10 + .../wallet/state/model/WalletDialogConfig.kt | 9 + 27 files changed, 904 insertions(+), 20 deletions(-) create mode 100644 domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayPushNotificationType.kt rename {data/visa/src/main/kotlin/com/tangem/data/visa => domain/visa/src/main/kotlin/com/tangem/domain/pay}/utils/TangemPayTxHistoryItemStatusConverter.kt (81%) create mode 100644 features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayTransactionBottomSheetComponent.kt create mode 100644 features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayMainDeepLinkHandler.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultTangemPayMainDeepLinkHandler.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushAction.kt create mode 100644 features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverter.kt create mode 100644 features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverterTest.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 23e1f1f8d7..72fdba54ab 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -20,6 +20,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -56,6 +57,7 @@ internal class DeepLinkFactory @Inject constructor( private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, + private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, private val newsDeepLink: NewsDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory, @@ -129,6 +131,10 @@ internal class DeepLinkFactory @Inject constructor( private fun handleHttpDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) { if (deeplinkUri.host == DeepLinkRoute.PayApp.host) { when { + deeplinkUri.path?.startsWith("/pay-app-main") == true -> { + tangemPayMainDeepLink.create(coroutineScope, getQueryParams(deeplinkUri)) + return + } deeplinkUri.path?.startsWith("/pay-app") == true -> { onboardVisaDeepLink.create(deeplinkUri) return @@ -168,6 +174,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 0c50d75372..b256302118 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -18,6 +18,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler @@ -82,6 +83,10 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val tangemPayMainDeepLink = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val cardSdkProvider = mockk(relaxed = true) { every { sdk.uiVisibility() } returns MutableStateFlow(false) } @@ -130,6 +135,7 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + tangemPayMainDeepLink = tangemPayMainDeepLink, newsDetailsDeepLink = newsDeeplink, newsDeepLink = newsDeepLinkFactory, earnDeepLink = earnDeepLinkFactory, @@ -358,6 +364,14 @@ class DeepLinkFactoryTest { deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { promoDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } + + // Test TangemPay + every { mockedUri.host } returns "pay-app-main" + every { mockedUri.queryParameterNames } returns setOf("param") + every { mockedUri.getQueryParameter("param") } returns "value" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { tangemPayMainDeepLink.create(eq(testScope), any()) } } @Test @@ -381,6 +395,7 @@ class DeepLinkFactoryTest { sellDeepLinkFactory.create() swapDeepLinkFactory.create() promoDeepLinkFactory.create(any(), any()) + tangemPayMainDeepLink.create(any(), any()) } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 0bdcdc9f27..ee03ee94b4 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -83,6 +83,10 @@ sealed class DeepLinkRoute { data object Yield : DeepLinkRoute() { override val host: String = "yield" } + + data object PayAppMain : DeepLinkRoute() { + override val host: String = "pay-app-main" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 5196edd70a..677f35d658 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -6,6 +6,8 @@ object DeeplinkConst { const val TANGEM_SCHEME = "tangem" const val WALLET_ID_KEY = "user_wallet_id" + const val CUSTOMER_WALLET_ID_KEY = "customer_wallet_id" + const val CUSTOMER_ID_KEY = "customer_id" const val NETWORK_ID_KEY = "network_id" const val TYPE_KEY = "type" const val TOKEN_ID_KEY = "token_id" diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverter.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverter.kt index 5bee03bf9b..2a1135d316 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverter.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverter.kt @@ -3,6 +3,7 @@ package com.tangem.common.routing.deeplink import android.os.Bundle import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme +import com.tangem.common.routing.deeplink.DeeplinkConst.CUSTOMER_WALLET_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.DEEPLINK_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.NAME_KEY @@ -11,6 +12,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.domain.visa.model.TangemPayPushNotificationType import com.tangem.utils.converter.Converter object PayloadToDeeplinkConverter : Converter, String?> { @@ -18,6 +20,7 @@ object PayloadToDeeplinkConverter : Converter, String?> { override fun convert(value: Map): String? { return when { value[DEEPLINK_KEY] != null -> value[DEEPLINK_KEY] + isTangemPayPushNotificationPayload(value) -> buildTangemPayNotificationDeeplink(value) isTangemPushNotificationPayload(value) -> buildNotificationDeeplink(value) else -> null } @@ -70,4 +73,19 @@ object PayloadToDeeplinkConverter : Converter, String?> { payload.containsKey(TOKEN_ID_KEY) && payload.containsKey(WALLET_ID_KEY) } + + private fun isTangemPayPushNotificationPayload(payload: Map): Boolean { + return payload.containsKey(CUSTOMER_WALLET_ID_KEY) && payload[TYPE_KEY] in TangemPayPushNotificationType.all + } + + private fun buildTangemPayNotificationDeeplink(payload: Map): String? { + val walletId = payload[CUSTOMER_WALLET_ID_KEY] + val type = payload[TYPE_KEY] + if (walletId.isNullOrEmpty() || type.isNullOrEmpty()) return null + + return DeepLinkBuilder().setScheme(DeepLinkScheme.Tangem.scheme).apply { + setAction(DeepLinkRoute.PayAppMain.host) + payload.forEach { (key, value) -> addQueryParam(key, value) } + }.build() + } } \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverterTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverterTest.kt index 03e08c352b..8dd6c1ba5d 100644 --- a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverterTest.kt +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PayloadToDeeplinkConverterTest.kt @@ -2,11 +2,14 @@ package com.tangem.common.routing.deeplink import com.google.common.truth.Truth.assertThat import com.tangem.common.routing.deeplink.DeeplinkConst.DEEPLINK_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.CUSTOMER_WALLET_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.domain.visa.model.TangemPayPushNotificationType import org.junit.Test internal class PayloadToDeeplinkConverterTest { @@ -145,4 +148,88 @@ internal class PayloadToDeeplinkConverterTest { // THEN assertThat(result).isNull() } + + @Test + fun `GIVEN tangem pay card_ready push payload WHEN convert THEN should return pay-app-main deeplink`() { + // GIVEN + val payload = mapOf( + TYPE_KEY to TangemPayPushNotificationType.CARD_READY.value, + CUSTOMER_WALLET_ID_KEY to "wallet123", + ) + + // WHEN + val result = PayloadToDeeplinkConverter.convert(payload) + + // THEN + assertThat(result).isEqualTo( + "tangem://pay-app-main?type=card_ready&customer_wallet_id=wallet123", + ) + } + + @Test + fun `GIVEN tangem pay transaction_spend push payload WHEN convert THEN should return pay-app-main deeplink with transaction_id`() { + // GIVEN + val payload = mapOf( + TYPE_KEY to TangemPayPushNotificationType.TRANSACTION_SPEND.value, + CUSTOMER_WALLET_ID_KEY to "wallet123", + TRANSACTION_ID_KEY to "test456", + ) + + // WHEN + val result = PayloadToDeeplinkConverter.convert(payload) + + // THEN + assertThat(result).isEqualTo( + "tangem://pay-app-main?type=transaction_spend&customer_wallet_id=wallet123&transaction_id=test456", + ) + } + + @Test + fun `GIVEN tangem pay top_up push payload WHEN convert THEN should return pay-app-main deeplink`() { + // GIVEN + val payload = mapOf( + TYPE_KEY to TangemPayPushNotificationType.TOP_UP.value, + CUSTOMER_WALLET_ID_KEY to "wallet123", + TRANSACTION_ID_KEY to "test456", + ) + + // WHEN + val result = PayloadToDeeplinkConverter.convert(payload) + + // THEN + assertThat(result).isEqualTo( + "tangem://pay-app-main?type=declined_top_up&customer_wallet_id=wallet123&transaction_id=test456", + ) + } + + @Test + fun `GIVEN tangem pay collateral push payload WHEN convert THEN should return pay-app-main deeplink`() { + // GIVEN + val payload = mapOf( + TYPE_KEY to TangemPayPushNotificationType.COLLATERAL.value, + CUSTOMER_WALLET_ID_KEY to "wallet123", + ) + + // WHEN + val result = PayloadToDeeplinkConverter.convert(payload) + + // THEN + assertThat(result).isEqualTo( + "tangem://pay-app-main?type=collateral&customer_wallet_id=wallet123", + ) + } + + @Test + fun `GIVEN tangem pay push payload with missing customer_wallet_id WHEN convert THEN should return null`() { + // GIVEN + val payload = mapOf( + TYPE_KEY to TangemPayPushNotificationType.CARD_READY.value, + ) + + // WHEN + val result = PayloadToDeeplinkConverter.convert(payload) + + // THEN + assertThat(result).isNull() + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt index c75c111c9d..d2d0b4e176 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemConverter.kt @@ -2,6 +2,7 @@ package com.tangem.data.visa.utils import com.squareup.moshi.Moshi import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse +import com.tangem.domain.pay.utils.TangemPayTxHistoryItemStatusConverter import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isPositive diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayPushNotificationType.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayPushNotificationType.kt new file mode 100644 index 0000000000..1ff9b157c8 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/TangemPayPushNotificationType.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.visa.model + +enum class TangemPayPushNotificationType(val value: String) { + CARD_READY("card_ready"), + TRANSACTION_SPEND("transaction_spend"), + TOP_UP("declined_top_up"), + COLLATERAL("collateral"), + ; + + companion object { + private val map = entries.associateBy { it.value } + + val all: Set = entries.map { it.value }.toSet() + + fun fromValue(value: String): TangemPayPushNotificationType? = map[value] + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/utils/TangemPayTxHistoryItemStatusConverter.kt similarity index 81% rename from data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/pay/utils/TangemPayTxHistoryItemStatusConverter.kt index e1ac53a595..25237faaac 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/TangemPayTxHistoryItemStatusConverter.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/utils/TangemPayTxHistoryItemStatusConverter.kt @@ -1,9 +1,9 @@ -package com.tangem.data.visa.utils +package com.tangem.domain.pay.utils import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.utils.converter.Converter -internal object TangemPayTxHistoryItemStatusConverter : Converter { +object TangemPayTxHistoryItemStatusConverter : Converter { override fun convert(value: String): TangemPayTxHistoryItem.Status { return when (value.uppercase()) { "PENDING" -> TangemPayTxHistoryItem.Status.PENDING diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayTransactionBottomSheetComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayTransactionBottomSheetComponent.kt new file mode 100644 index 0000000000..61d70dcfd0 --- /dev/null +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayTransactionBottomSheetComponent.kt @@ -0,0 +1,19 @@ +package com.tangem.features.tangempay.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayTxHistoryItem + +interface TangemPayTransactionBottomSheetComponent : ComposableBottomSheetComponent { + + data class Params( + val isBalanceHidden: Boolean, + val transaction: TangemPayTxHistoryItem, + val userWalletId: UserWalletId, + val customerId: String, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index faa746e4b8..8bcbbce246 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -94,7 +94,7 @@ internal class TangemPayDetailsComponent( ) is TangemPayDetailsNavigation.TransactionDetails -> TangemPayTxHistoryDetailsComponent( appComponentContext = context, - params = TangemPayTxHistoryDetailsComponent.Params( + params = TangemPayTransactionBottomSheetComponent.Params( transaction = navigation.transaction, isBalanceHidden = navigation.isBalanceHidden, userWalletId = params.initialStatus.userWalletId, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt index c2792e7e33..b0a795d1af 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/txHistory/TangemPayTxHistoryDetailsComponent.kt @@ -5,16 +5,17 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.ui.TangemPayTxHistoryDetailsContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject -internal class TangemPayTxHistoryDetailsComponent( - appComponentContext: AppComponentContext, - params: Params, -) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { +internal class TangemPayTxHistoryDetailsComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: TangemPayTransactionBottomSheetComponent.Params, +) : TangemPayTransactionBottomSheetComponent, AppComponentContext by appComponentContext { private val model: TangemPayTxHistoryDetailsModel = getOrCreateModel(params = params) @@ -28,11 +29,11 @@ internal class TangemPayTxHistoryDetailsComponent( TangemPayTxHistoryDetailsContent(state = state) } - data class Params( - val transaction: TangemPayTxHistoryItem, - val isBalanceHidden: Boolean, - val userWalletId: UserWalletId, - val customerId: String, - val onDismiss: () -> Unit, - ) + @AssistedFactory + interface Factory : TangemPayTransactionBottomSheetComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayTransactionBottomSheetComponent.Params, + ): TangemPayTxHistoryDetailsComponent + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index de293a174d..fd27c59344 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -2,6 +2,8 @@ package com.tangem.features.tangempay.di import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent +import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener import dagger.Binds @@ -23,4 +25,10 @@ internal interface TangemPayDetailsFeatureModule { @Binds @Singleton fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener + + @Binds + @Singleton + fun bindTangemPayTransactionBottomSheetComponentFactory( + factory: TangemPayTxHistoryDetailsComponent.Factory, + ): TangemPayTransactionBottomSheetComponent.Factory } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt index 9569233780..e430f443af 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt @@ -13,7 +13,7 @@ import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent +import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -34,7 +34,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( paramsContainer: ParamsContainer, ) : Model() { - private val params = paramsContainer.require() + private val params = paramsContainer.require() val uiState: StateFlow field = MutableStateFlow( value = TangemPayTxHistoryDetailsConverter.convert( diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayMainDeepLinkHandler.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayMainDeepLinkHandler.kt new file mode 100644 index 0000000000..168c0a257a --- /dev/null +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayMainDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface TangemPayMainDeepLinkHandler { + + interface Factory { + fun create(scope: CoroutineScope, payload: Map): TangemPayMainDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultTangemPayMainDeepLinkHandler.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultTangemPayMainDeepLinkHandler.kt new file mode 100644 index 0000000000..d130d1683f --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/DefaultTangemPayMainDeepLinkHandler.kt @@ -0,0 +1,118 @@ +package com.tangem.features.tangempay.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.CUSTOMER_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.CUSTOMER_WALLET_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.visa.model.TangemPayPushNotificationType +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.SelectWalletUseCase +import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DefaultTangemPayMainDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val payload: Map, + private val appRouter: AppRouter, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val selectWalletUseCase: SelectWalletUseCase, + private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val paymentAccountSupplier: PaymentAccountStatusSupplier, +) : TangemPayMainDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val walletId = payload[CUSTOMER_WALLET_ID_KEY] + + scope.launch { + val userWalletId = walletId?.let(::UserWalletId) ?: run { + appRouter.popTo(AppRoute.Wallet) + return@launch + } + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() + if (userWallet == null || userWallet.isLocked) { + appRouter.popTo(AppRoute.Wallet) + return@launch + } + if (selectWalletUseCase(userWalletId).getOrNull() == null) { + appRouter.popTo(AppRoute.Wallet) + return@launch + } + + val pushAction = buildPushAction() + + appRouter.popTo( + route = AppRoute.Wallet, + onComplete = { + walletDeepLinkActionTrigger.selectWallet(userWalletId) + when (pushAction) { + is TangemPayPushAction.CardReady, + is TangemPayPushAction.TopUp, + -> navigateToTangemPayDetails(userWalletId) + is TangemPayPushAction.TransactionSpend -> { + walletDeepLinkActionTrigger.showTangemPayTransaction( + transaction = pushAction.transaction, + customerId = pushAction.customerId, + ) + } + is TangemPayPushAction.CollateralTransaction -> { + walletDeepLinkActionTrigger.showTangemPayTransaction( + transaction = pushAction.transaction, + customerId = pushAction.customerId, + ) + } + null -> Unit + } + }, + ) + } + } + + private fun buildPushAction(): TangemPayPushAction? { + val type = payload[TYPE_KEY]?.let(TangemPayPushNotificationType::fromValue) ?: return null + val customerId = payload[CUSTOMER_ID_KEY].orEmpty() + + return when (type) { + TangemPayPushNotificationType.CARD_READY -> TangemPayPushAction.CardReady + TangemPayPushNotificationType.TRANSACTION_SPEND -> { + val transaction = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + if (transaction != null) TangemPayPushAction.TransactionSpend(transaction, customerId) else null + } + TangemPayPushNotificationType.TOP_UP -> TangemPayPushAction.TopUp + TangemPayPushNotificationType.COLLATERAL -> { + val transaction = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + if (transaction != null) TangemPayPushAction.CollateralTransaction(transaction, customerId) else null + } + } + } + + private fun navigateToTangemPayDetails(walletId: UserWalletId) { + scope.launch { + paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(walletId)) + val paymentAccountStatus = paymentAccountSupplier.invoke(userWalletId = walletId) + .firstOrNull() + ?: return@launch + appRouter.push(route = AppRoute.TangemPayDetails(status = paymentAccountStatus)) + } + } + + @AssistedFactory + interface Factory : TangemPayMainDeepLinkHandler.Factory { + override fun create(scope: CoroutineScope, payload: Map): DefaultTangemPayMainDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushAction.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushAction.kt new file mode 100644 index 0000000000..4ae61bc268 --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushAction.kt @@ -0,0 +1,20 @@ +package com.tangem.features.tangempay.deeplink + +import com.tangem.domain.visa.model.TangemPayTxHistoryItem + +internal sealed class TangemPayPushAction { + + data object CardReady : TangemPayPushAction() + + data class TransactionSpend( + val transaction: TangemPayTxHistoryItem, + val customerId: String, + ) : TangemPayPushAction() + + data object TopUp : TangemPayPushAction() + + data class CollateralTransaction( + val transaction: TangemPayTxHistoryItem, + val customerId: String, + ) : TangemPayPushAction() +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverter.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverter.kt new file mode 100644 index 0000000000..b81a838b3a --- /dev/null +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverter.kt @@ -0,0 +1,90 @@ +package com.tangem.features.tangempay.deeplink + +import com.tangem.domain.pay.utils.TangemPayTxHistoryItemStatusConverter +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.utils.extensions.orZero +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.math.BigDecimal +import java.util.Currency + +object TangemPayPushPayloadToTxHistoryItemConverter { + + private const val KEY_ID = "transaction_id" + private const val KEY_AMOUNT = "amount" + private const val KEY_CURRENCY = "currency" + private const val KEY_LOCAL_AMOUNT = "local_amount" + private const val KEY_LOCAL_CURRENCY = "local_currency" + private const val KEY_AUTHORIZED_AMOUNT = "authorized_amount" + private const val KEY_MERCHANT_NAME = "merchant_name" + private const val KEY_ENRICHED_MERCHANT_NAME = "enriched_merchant_name" + private const val KEY_ENRICHED_MERCHANT_ICON = "enriched_merchant_icon" + private const val KEY_ENRICHED_MERCHANT_CATEGORY = "enriched_merchant_category" + private const val KEY_MERCHANT_CATEGORY = "merchant_category" + private const val KEY_MERCHANT_CATEGORY_CODE = "merchant_category_code" + private const val KEY_STATUS = "status" + private const val KEY_DECLINED_REASON = "declined_reason" + private const val KEY_AUTHORIZED_AT = "authorized_at" + private const val KEY_POSTED_AT = "posted_at" + private const val KEY_TRANSACTION_HASH = "transaction_hash" + + @Suppress("ComplexCondition") + fun convertSpend(payload: Map): TangemPayTxHistoryItem.Spend? { + val id = payload[KEY_ID]?.ifEmpty { null } ?: return null + val amount = payload[KEY_AMOUNT]?.toBigDecimalOrNull() ?: return null + val currency = payload[KEY_CURRENCY]?.let(::parseCurrency) ?: return null + val merchantName = payload[KEY_MERCHANT_NAME] ?: payload[KEY_ENRICHED_MERCHANT_NAME] ?: return null + val status = payload[KEY_STATUS]?.ifEmpty { null } ?: return null + val authorizedAt = payload[KEY_AUTHORIZED_AT]?.let(::parseDateTime) ?: return null + + return TangemPayTxHistoryItem.Spend( + id = id, + jsonRepresentation = payload.toString(), + date = authorizedAt.withZone(DateTimeZone.getDefault()), + amount = amount, + currency = currency, + authorizedAmount = payload[KEY_AUTHORIZED_AMOUNT]?.toBigDecimalOrNull().orZero(), + localAmount = payload[KEY_LOCAL_AMOUNT]?.toBigDecimalOrNull(), + localCurrency = payload[KEY_LOCAL_CURRENCY]?.let(::parseCurrency), + enrichedMerchantName = payload[KEY_ENRICHED_MERCHANT_NAME], + merchantName = merchantName, + enrichedMerchantCategory = payload[KEY_ENRICHED_MERCHANT_CATEGORY], + merchantCategoryCode = payload[KEY_MERCHANT_CATEGORY_CODE], + merchantCategory = payload[KEY_MERCHANT_CATEGORY], + status = TangemPayTxHistoryItemStatusConverter.convert(status), + enrichedMerchantIconUrl = payload[KEY_ENRICHED_MERCHANT_ICON], + declinedReason = payload[KEY_DECLINED_REASON], + ) + } + + @Suppress("ComplexCondition") + fun convertCollateral(payload: Map): TangemPayTxHistoryItem.Collateral? { + val id = payload[KEY_ID]?.ifEmpty { null } ?: return null + val amount = payload[KEY_AMOUNT]?.toBigDecimalOrNull() ?: return null + val transactionHash = payload[KEY_TRANSACTION_HASH]?.ifEmpty { null } ?: return null + val postedAt = payload[KEY_POSTED_AT]?.let(::parseDateTime) ?: return null + val currency = payload[KEY_CURRENCY]?.let(::parseCurrency) ?: return null + + return TangemPayTxHistoryItem.Collateral( + id = id, + jsonRepresentation = payload.toString(), + date = postedAt.withZone(DateTimeZone.getDefault()), + currency = currency, + amount = amount, + transactionHash = transactionHash, + type = if (amount >= BigDecimal.ZERO) { + TangemPayTxHistoryItem.Type.Deposit + } else { + TangemPayTxHistoryItem.Type.Withdrawal + }, + ) + } + + private fun parseCurrency(code: String): Currency? = runCatching { + return Currency.getInstance(code.uppercase()) + }.getOrNull() + + private fun parseDateTime(value: String): DateTime? = runCatching { + return DateTime.parse(value).withZone(DateTimeZone.getDefault()) + }.getOrNull() +} \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt index a1d13bcabc..69c0ece466 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDeeplinkModule.kt @@ -1,7 +1,9 @@ package com.tangem.features.tangempay.di import com.tangem.features.tangempay.deeplink.DefaultOnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.DefaultTangemPayMainDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,4 +17,10 @@ internal interface TangemPayDeeplinkModule { @Binds @Singleton fun bindDeepLinkHandlerFactory(impl: DefaultOnboardVisaDeepLinkHandler.Factory): OnboardVisaDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindTangemPayMainDeepLinkHandlerFactory( + impl: DefaultTangemPayMainDeepLinkHandler.Factory, + ): TangemPayMainDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverterTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverterTest.kt new file mode 100644 index 0000000000..9094eb879b --- /dev/null +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/deeplink/TangemPayPushPayloadToTxHistoryItemConverterTest.kt @@ -0,0 +1,400 @@ +package com.tangem.features.tangempay.deeplink + +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class TangemPayPushPayloadToTxHistoryItemConverterTest { + + @Test + fun `convertSpend returns Spend tx with all fields when payload is complete`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "local_amount" to "5.24", + "local_currency" to "usd", + "authorized_amount" to "5.24", + "merchant_name" to "PLAYSTATION NETWORK", + "enriched_merchant_name" to "Playstation", + "enriched_merchant_icon" to "https://example.com/icon.png", + "enriched_merchant_category" to "Gaming", + "merchant_category" to "Digital Goods", + "merchant_category_code" to "5818", + "status" to "completed", + "declined_reason" to "", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNotNull() + assertThat(result!!.id).isEqualTo("txn-123") + assertThat(result.amount).isEqualTo(BigDecimal("5.24")) + assertThat(result.currency.currencyCode).isEqualTo("USD") + assertThat(result.localAmount).isEqualTo(BigDecimal("5.24")) + assertThat(result.localCurrency?.currencyCode).isEqualTo("USD") + assertThat(result.authorizedAmount).isEqualTo(BigDecimal("5.24")) + assertThat(result.merchantName).isEqualTo("PLAYSTATION NETWORK") + assertThat(result.enrichedMerchantName).isEqualTo("Playstation") + assertThat(result.enrichedMerchantIconUrl).isEqualTo("https://example.com/icon.png") + assertThat(result.enrichedMerchantCategory).isEqualTo("Gaming") + assertThat(result.merchantCategory).isEqualTo("Digital Goods") + assertThat(result.merchantCategoryCode).isEqualTo("5818") + assertThat(result.status).isEqualTo(TangemPayTxHistoryItem.Status.COMPLETED) + } + + @Test + fun `convertSpend returns null when transaction_id is missing`() { + val payload = mapOf( + "amount" to "5.24", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when amount is missing`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when currency is missing`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when merchant_name is missing`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend falls back to enriched_merchant_name when merchant_name is absent`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "enriched_merchant_name" to "Playstation", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNotNull() + assertThat(result!!.merchantName).isEqualTo("Playstation") + } + + @Test + fun `convertSpend returns null when status is missing`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "merchant_name" to "Test", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when authorized_at is missing`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when amount is not a number`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "abc", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend returns null when currency is invalid`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "INVALID", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend maps all statuses correctly`() { + fun spendPayloadWithStatus(status: String) = mapOf( + "transaction_id" to "txn-123", + "amount" to "1.00", + "currency" to "usd", + "merchant_name" to "Test", + "status" to status, + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("pending"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.PENDING) + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("reserved"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.RESERVED) + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("completed"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.COMPLETED) + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("declined"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.DECLINED) + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("reversed"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.REVERSED) + assertThat(TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(spendPayloadWithStatus("unknown_value"))!!.status) + .isEqualTo(TangemPayTxHistoryItem.Status.UNKNOWN) + } + + @Test + fun `convertSpend returns null when transaction_id is empty`() { + val payload = mapOf( + "transaction_id" to "", + "amount" to "5.24", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns Collateral with all fields when payload is complete`() { + val payload = mapOf( + "transaction_id" to "col-123", + "amount" to "50.00", + "currency" to "usd", + "transaction_hash" to "0xabc123", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNotNull() + assertThat(result!!.id).isEqualTo("col-123") + assertThat(result.amount).isEqualTo(BigDecimal("50.00")) + assertThat(result.currency.currencyCode).isEqualTo("USD") + assertThat(result.transactionHash).isEqualTo("0xabc123") + assertThat(result.type).isEqualTo(TangemPayTxHistoryItem.Type.Deposit) + } + + @Test + fun `convertCollateral returns Withdrawal type for negative amount`() { + val payload = mapOf( + "transaction_id" to "col-456", + "amount" to "-10.00", + "currency" to "usd", + "transaction_hash" to "0xdef789", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNotNull() + assertThat(result!!.type).isEqualTo(TangemPayTxHistoryItem.Type.Withdrawal) + } + + @Test + fun `convertCollateral returns null when transaction_id is missing`() { + val payload = mapOf( + "amount" to "50.00", + "currency" to "usd", + "transaction_hash" to "0xabc123", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null when amount is missing`() { + val payload = mapOf( + "transaction_id" to "col-123", + "currency" to "usd", + "transaction_hash" to "0xabc123", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null when transaction_hash is missing`() { + val payload = mapOf( + "transaction_id" to "col-123", + "amount" to "50.00", + "currency" to "usd", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null when posted_at is missing`() { + val payload = mapOf( + "transaction_id" to "col-123", + "amount" to "50.00", + "currency" to "usd", + "transaction_hash" to "0xabc123", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null when currency is missing`() { + val payload = mapOf( + "transaction_id" to "col-123", + "amount" to "50.00", + "transaction_hash" to "0xabc123", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null when transaction_hash is empty`() { + val payload = mapOf( + "transaction_id" to "col-123", + "amount" to "50.00", + "currency" to "usd", + "transaction_hash" to "", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNull() + } + + @Test + fun `convertSpend handles optional fields as null`() { + val payload = mapOf( + "transaction_id" to "txn-123", + "amount" to "5.24", + "currency" to "usd", + "merchant_name" to "Test", + "status" to "completed", + "authorized_at" to "2025-10-24T10:32:24.496Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(payload) + + assertThat(result).isNotNull() + assertThat(result!!.localAmount).isNull() + assertThat(result.localCurrency).isNull() + assertThat(result.enrichedMerchantName).isNull() + assertThat(result.enrichedMerchantIconUrl).isNull() + assertThat(result.enrichedMerchantCategory).isNull() + assertThat(result.merchantCategory).isNull() + assertThat(result.merchantCategoryCode).isNull() + assertThat(result.declinedReason).isNull() + } + + @Test + fun `convertCollateral returns Deposit type for zero amount`() { + val payload = mapOf( + "transaction_id" to "col-789", + "amount" to "0", + "currency" to "usd", + "transaction_hash" to "0xdef", + "posted_at" to "2025-10-25T19:22:22.597Z", + ) + + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(payload) + + assertThat(result).isNotNull() + assertThat(result!!.type).isEqualTo(TangemPayTxHistoryItem.Type.Deposit) + } + + @Test + fun `convertSpend returns null for empty payload`() { + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertSpend(emptyMap()) + assertThat(result).isNull() + } + + @Test + fun `convertCollateral returns null for empty payload`() { + val result = TangemPayPushPayloadToTxHistoryItemConverter.convertCollateral(emptyMap()) + assertThat(result).isNull() + } +} diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts index 4c23e065f7..a5628d2fed 100644 --- a/features/wallet/api/build.gradle.kts +++ b/features/wallet/api/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { /** Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.visa.models) /** Tangem libraries */ implementation(tangemDeps.card.core) diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/SelectWalletInDeepLinkTrigger.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/SelectWalletInDeepLinkTrigger.kt index 0c3ddcdc5a..c3187740b7 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/SelectWalletInDeepLinkTrigger.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/deeplink/SelectWalletInDeepLinkTrigger.kt @@ -1,12 +1,20 @@ package com.tangem.features.wallet.deeplink import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayTxHistoryItem import kotlinx.coroutines.flow.Flow interface WalletDeepLinkActionTrigger { fun selectWallet(userWalletId: UserWalletId) + fun showTangemPayTransaction(transaction: TangemPayTxHistoryItem, customerId: String) } interface WalletDeepLinkActionListener { val selectWalletFlow: Flow -} \ No newline at end of file + val showTangemPayTransactionFlow: Flow +} + +data class TangemPayTransactionDeepLinkData( + val transaction: TangemPayTxHistoryItem, + val customerId: String, +) \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 53bfe57f10..9c8ed35199 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -152,6 +152,7 @@ dependencies { implementation(projects.features.feed.api) implementation(projects.features.promoBanners.api) implementation(projects.features.tangempay.main.api) + implementation(projects.features.tangempay.details.api) /** Common modules */ implementation(projects.common) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 622fc2b25b..2ae29d7123 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -42,6 +42,7 @@ import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetCom import com.tangem.features.pushnotifications.api.PushNotificationsParams import com.tangem.features.send.v2.api.NetworkSelectionComponent import com.tangem.features.tangempay.component.TangemPayMainBlockComponent +import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import dagger.assisted.Assisted @@ -56,6 +57,7 @@ internal class WalletComponent @AssistedInject constructor( @Assisted navigate: (WalletRoute) -> Unit, feedEntryComponentFactory: FeedEntryComponent.Factory, tangemPayMainBlockComponentFactory: TangemPayMainBlockComponent.Factory, + private val tangemPayTransactionBottomSheetComponentFactory: TangemPayTransactionBottomSheetComponent.Factory, private val renameWalletComponentFactory: RenameWalletComponent.Factory, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory, @@ -242,6 +244,18 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.TangemPayTransactionDetails -> { + tangemPayTransactionBottomSheetComponentFactory.create( + context = childByContext(componentContext), + params = TangemPayTransactionBottomSheetComponent.Params( + isBalanceHidden = dialogConfig.isBalanceHidden, + transaction = dialogConfig.transaction, + userWalletId = dialogConfig.walletId, + customerId = dialogConfig.customerId, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + ), + ) + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 993e9512ba..71d49f3081 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -158,6 +158,7 @@ internal class WalletModel @Inject constructor( subscribeToScreenBackgroundState() subscribeOnPushNotificationsPermission() subscribeTangemPayOnWalletState() + subscribeToTangemPayTransactionDeepLink() subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() applyPendingAssetsDiscovery() @@ -780,6 +781,21 @@ internal class WalletModel @Inject constructor( .launchIn(modelScope) } + private fun subscribeToTangemPayTransactionDeepLink() { + walletDeepLinkActionListener.showTangemPayTransactionFlow + .onEach { data -> + innerWalletRouter.dialogNavigation.activate( + WalletDialogConfig.TangemPayTransactionDetails( + isBalanceHidden = stateHolder.value.isHidingMode, + transaction = data.transaction, + walletId = stateHolder.getSelectedWalletId(), + customerId = data.customerId, + ), + ) + } + .launchIn(modelScope) + } + private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) { val target = resolveQrSendTargetsUseCase(qrCode) handleQrTarget(target, resultSource) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkActionTrigger.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkActionTrigger.kt index 1a2337b6de..68ff76a876 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkActionTrigger.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultWalletDeepLinkActionTrigger.kt @@ -1,6 +1,8 @@ package com.tangem.feature.wallet.deeplink import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.features.wallet.deeplink.TangemPayTransactionDeepLinkData import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger import kotlinx.coroutines.channels.Channel @@ -18,7 +20,15 @@ internal class DefaultWalletDeepLinkActionTrigger @Inject constructor() : override val selectWalletFlow: Flow get() = _selectWalletFlow.receiveAsFlow() + private val _showTangemPayTransactionFlow = Channel() + override val showTangemPayTransactionFlow: Flow + get() = _showTangemPayTransactionFlow.receiveAsFlow() + override fun selectWallet(userWalletId: UserWalletId) { _selectWalletFlow.trySend(userWalletId) } + + override fun showTangemPayTransaction(transaction: TangemPayTxHistoryItem, customerId: String) { + _showTangemPayTransactionFlow.trySend(TangemPayTransactionDeepLinkData(transaction, customerId)) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 52cb3834c7..442b0f1268 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.BigDecimalSerializer import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.domain.visa.model.TangemPayTxHistoryItem import kotlinx.collections.immutable.ImmutableList import kotlinx.serialization.Serializable import java.math.BigDecimal @@ -53,6 +54,14 @@ internal sealed interface WalletDialogConfig { @Serializable data class AddAndManage(val userWalletId: UserWalletId) : WalletDialogConfig + @Serializable + data class TangemPayTransactionDetails( + val isBalanceHidden: Boolean, + val transaction: TangemPayTxHistoryItem, + val walletId: UserWalletId, + val customerId: String, + ) : WalletDialogConfig + @Serializable data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig