Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 00:27:16 -07:00
parent db28e42f72
commit 25444c3a2d
17 changed files with 415 additions and 50 deletions

View file

@ -672,7 +672,10 @@ internal class ChildFactory @Inject constructor(
is AppRoute.TangemPayDetails -> {
createComponentChild(
context = context,
params = TangemPayDetailsContainerComponent.Params(initialStatus = route.status),
params = TangemPayDetailsContainerComponent.Params(
initialStatus = route.status,
initialRoute = route.initialRoute,
),
componentFactory = tangemPayDetailsContainerComponentFactory,
)
}

View file

@ -21,6 +21,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
@ -487,6 +488,7 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class TangemPayDetails(
val status: AccountStatus.Payment,
val initialRoute: TangemPayDetailsInitialRoute = TangemPayDetailsInitialRoute.ACCOUNT_DETAILS,
) : AppRoute(path = "/tangem_pay_details/${status.account}")
@Serializable

View file

@ -94,6 +94,7 @@ dependencies {
// region Project - Features
api(projects.features.swap.domain)
api(projects.features.virtualAccounts.details.api)
api(projects.features.tangempay.details.api)
// endregion
// region Project - Libs

View file

@ -17,11 +17,12 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDat
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
@ -64,6 +65,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase,
) : PaymentAccountStatusFetcher {
@ -174,17 +176,65 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId)
if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(account.userWalletId)
.onLeft { TangemLogger.withTag(TAG).e("createOrder failed: $it") }
}
status
resolveFinalStatus(
account = account,
customerInfo = customerInfo,
status = customerInfo.mapToPaymentAccountStatus(account.userWalletId),
)
},
)
}
private suspend fun resolveFinalStatus(
account: Account.Payment,
customerInfo: CustomerInfo,
status: PaymentAccountStatusValue,
): PaymentAccountStatusValue {
val isIssuing = status is PaymentAccountStatusValue.IssuingCard
val isApproved = customerInfo.kycStatus == KycStatus.APPROVED
val userWalletId = account.userWalletId
val tariffPlan = customerInfo.tariffPlan
if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled) {
if (isIssuing && isApproved) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(userWalletId)
.onLeft { logger.e("createOrder failed: $it") }
}
return status
}
if (!isIssuing || !isApproved) {
return status
}
if (tariffPlan == null) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
val hasActiveIssueOrder = issueCardRepository.getIssueOrderIds(userWalletId).isNotEmpty()
return if (hasActiveIssueOrder) {
PaymentAccountStatusValue.Inactive(
source = StatusSource.ACTUAL,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
tariffPlan = getTangemPayTariffPlanStateUseCase(
userWalletId = userWalletId,
tariff = tariffPlan,
),
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.ZERO,
currency = tariffPlan.plan.feeCurrencyOrDefault(),
),
)
} else {
PaymentAccountStatusValue.AwaitingPlanSelection(
source = StatusSource.ACTUAL,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
tariffPlan = tariffPlan,
)
}
}
private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue {
// Step 1: Check KYC status first
val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold(
@ -286,24 +336,26 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val customerId = customerId
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
val fiatBalance = fiatBalance
val cryptoBalance = cryptoBalance
val hasCardData = cards.isNotEmpty() && productInstances.isNotEmpty()
val isTiersPlusPlanEnabled = tangemPayFeatureToggles.isTiersPlusPlanEnabled
return when {
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
customerId.isNullOrEmpty() -> PaymentAccountStatusValue.IssuingCard(
source = StatusSource.ACTUAL,
)
kycStatus != KycStatus.APPROVED -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = customerId,
)
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) ->
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
customerId = customerId,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
@ -313,15 +365,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
fiatRate = quotesData?.fiatRate,
error = null,
)
}
cards.isNotEmpty() && productInstances.isNotEmpty() &&
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
fiatBalance != null && cryptoBalance != null && (hasCardData || isTiersPlusPlanEnabled) ->
convertToContentState(
userWalletId = userWalletId,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = customerId,
)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
}
@ -365,7 +416,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled && tangemPayCards.isEmpty()) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
// Additional-card issuance: the backend omits the new card until it is provisioned, so surface a
// placeholder for every locally tracked in-flight issuance order alongside the real cards.
@ -376,6 +429,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
// the previously shown order and append newly seen cards at the end.
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
val allCards = orderedCards + issuingCards
if (allCards.isEmpty()) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
val virtualAccount = resolveVirtualAccountOnramp(userWalletId)
return PaymentAccountStatusValue.Loaded(
@ -384,7 +443,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = orderedCards + issuingCards,
cards = allCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,

View file

@ -7,11 +7,15 @@ import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -22,6 +26,7 @@ import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -49,6 +54,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk()
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk()
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase = mockk()
private val fetcher = DefaultPaymentAccountStatusFetcher(
@ -65,6 +71,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
tangemPayFeatureToggles = tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
)
@ -111,27 +118,47 @@ internal class DefaultPaymentAccountStatusFetcherTest {
images = emptyList(),
)
private val basicPlan = TangemPayTariffPlan(
id = "plan_basic",
type = TangemPayTariffPlan.Type.BASIC,
name = "Basic",
descriptionItems = emptyList(),
images = emptyList(),
fees = emptyList(),
)
private val customerTariffPlan = TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.ACTIVE,
plan = basicPlan,
nextBillingAt = null,
pendingPlan = null,
pendingTransitionAt = null,
)
private fun buildCustomerInfo(
productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance),
) = CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
fiatBalance: PaymentAccountStatusValue.FiatBalance? = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.TEN,
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
cryptoBalance: PaymentAccountStatusValue.CryptoBalance? = PaymentAccountStatusValue.CryptoBalance(
id = "usdc",
chainId = 137L,
depositAddress = "0xdeposit",
tokenContractAddress = "0xcontract",
balance = BigDecimal.TEN,
),
tariffPlan: TangemPayCustomerTariffPlan? = null,
) = CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = BigDecimal.TEN,
cards = listOf(cardInfo),
productInstances = productInstances,
tariffPlan = null,
tariffPlan = tariffPlan,
)
@BeforeEach
@ -146,10 +173,14 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository,
issueCardRepository,
virtualAccountFeatureToggles,
tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase,
)
// Relaxed mocks don't need clearing — deviceSecurity, eligibilityManager, paymentAccountStatusesStore
// are relaxed and consistent with their relaxed defaults (false, empty, etc.)
clearMocks(paymentAccountStatusesStore, answers = false)
// Tiers off by default — legacy auto-order-creation behavior. Individual tests override.
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
}
/**
@ -304,4 +335,142 @@ internal class DefaultPaymentAccountStatusFetcherTest {
assertThat(loaded.virtualAccount).isNull()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class TiersPlanSelection {
@Test
fun `GIVEN tiers on and KYC approved and no plan order WHEN invoke THEN stores AwaitingPlanSelection`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = customerTariffPlan,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns emptyList()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.AwaitingPlanSelection::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers on and fallback plan is missing WHEN invoke THEN stores IssuingCard without order`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = null,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers on and plan selected but no balance yet WHEN invoke THEN stores Inactive`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = customerTariffPlan,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery {
getTangemPayTariffPlanStateUseCase(userWalletId = userWalletId, tariff = customerTariffPlan)
} returns TangemPayTariffPlanState(tariff = customerTariffPlan, order = null)
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.Inactive::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers off and KYC approved without card WHEN invoke THEN creates order and stays issuing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1")
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
coVerify(exactly = 1) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers off and balance without instances but local issue order WHEN invoke THEN stores IssuingCard`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1")
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
}
@Test
fun `GIVEN tiers on and balance without instances but local issue order WHEN invoke THEN stores Loaded with placeholder`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery {
cardDetailsRepository.getOrderInfo(userWalletId, "order_1")
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.cards).hasSize(1)
assertThat(loaded.cards.single().state).isEqualTo(TangemPayCardState.Issuing)
}
}
}

View file

@ -149,4 +149,8 @@ data class TangemPayTariffPlan(
}
}
}
}
fun TangemPayTariffPlan.feeCurrencyOrDefault(defaultCurrencyCode: String = "USD"): String {
return fees.firstOrNull()?.currency ?: defaultCurrencyCode
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.models.pay
import kotlinx.serialization.Serializable
/**
* Which inner screen the Tangem Pay should open on when launched.
*
* [ACCOUNT_DETAILS] the default account/main page.
* [SELECT_PLAN] the tariff-plan selection screen, used by the "Select plan" entry point on the
* wallet main screen (Tiers).
*/
@Serializable
enum class TangemPayDetailsInitialRoute {
ACCOUNT_DETAILS,
SELECT_PLAN,
}

View file

@ -3,8 +3,12 @@ package com.tangem.features.tangempay.components
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
interface TangemPayDetailsContainerComponent : ComposableContentComponent {
data class Params(val initialStatus: AccountStatus.Payment)
data class Params(
val initialStatus: AccountStatus.Payment,
val initialRoute: TangemPayDetailsInitialRoute,
)
interface Factory : ComponentFactory<Params, TangemPayDetailsContainerComponent>
}

View file

@ -15,11 +15,13 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent
import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanComponent
import com.tangem.features.tangempay.utils.tariffPlan
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -51,10 +53,22 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
key = "tangemPayDetailsInnerStack",
source = stackNavigation,
serializer = TangemPayAccountDetailsInnerRoute.serializer(),
initialConfiguration = TangemPayAccountDetailsInnerRoute.AccountDetails,
initialConfiguration = resolveInitialConfiguration(),
childFactory = ::screenChild,
)
private fun resolveInitialConfiguration(): TangemPayAccountDetailsInnerRoute {
val tariffPlan = params.initialStatus.tariffPlan
return when (params.initialRoute) {
TangemPayDetailsInitialRoute.ACCOUNT_DETAILS -> TangemPayAccountDetailsInnerRoute.AccountDetails
TangemPayDetailsInitialRoute.SELECT_PLAN -> if (tariffPlan != null) {
TangemPayAccountDetailsInnerRoute.SelectPlan(tariffPlan = tariffPlan)
} else {
TangemPayAccountDetailsInnerRoute.AccountDetails
}
}
}
@Composable
override fun Content(modifier: Modifier) {
val childStack by childStack.subscribeAsState()

View file

@ -56,7 +56,6 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.feeCurrency
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tokendetails.ExpressTransactionsEvent
import com.tangem.features.tokendetails.ExpressTransactionsEventListener

View file

@ -9,9 +9,6 @@ import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import org.joda.time.format.DateTimeFormatter
val TangemPayTariffPlan.feeCurrency: String?
get() = fees.firstOrNull()?.currency
fun TangemPayTariffPlan.formatRecurringFeeOrNull(): String? {
val fee = fees.find { it.type == TangemPayTariffPlan.Fee.Type.RECURRING } ?: return null
val currency = getJavaCurrencyByCode(fee.currency)

View file

@ -50,6 +50,9 @@ internal class TangemPaySelectPlanModel @Inject constructor(
private val allowedTransitions: List<TangemPayTariffPlanTransition>
get() = transitions.filter { it.type in ALLOWED_TYPES }
private val allowedTransitionsForCompare: List<TangemPayTariffPlanTransition>
get() = transitions.filter { it.type in ALLOWED_TYPES_FOR_COMPARE }
private var selectedIndex: Int = 0
private var isConfirm: Boolean = false
private var isProcessing: Boolean = false
@ -130,7 +133,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
state.update { buildState() }
modelScope.launch {
action().fold(
ifRight = { router.popTo(TangemPayAccountDetailsInnerRoute.AccountDetails) },
ifRight = { router.replaceAll(TangemPayAccountDetailsInnerRoute.AccountDetails) },
ifLeft = {
isProcessing = false
state.update { buildState() }
@ -163,7 +166,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
)
private fun buildCompare(): TangemPaySelectPlanUM.ComparePlans {
val plans = listOf(params.tariffPlan.plan) + transitions.map { it.plan }
val plans = listOf(params.tariffPlan.plan) + allowedTransitionsForCompare.map { it.plan }
val orderedTitles = plans
.flatMap { plan -> plan.descriptionItems.filter { it.section in COMPARE_SECTIONS } }
.sortedWith(compareBy({ it.section.ordinal }, { it.order }))
@ -282,6 +285,10 @@ internal class TangemPaySelectPlanModel @Inject constructor(
TangemPayTariffPlanTransition.Type.DOWNGRADE,
TangemPayTariffPlanTransition.Type.ACTIVATION,
)
private val ALLOWED_TYPES_FOR_COMPARE = setOf(
TangemPayTariffPlanTransition.Type.UPGRADE,
TangemPayTariffPlanTransition.Type.DOWNGRADE,
)
private val COMPARE_SECTIONS = setOf(
TangemPayTariffPlan.Section.CARD_RELATED,
TangemPayTariffPlan.Section.PLAN_RELATED,

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.utils
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
@ -17,6 +18,15 @@ internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token
else -> error("TangemPayDetails opened with unsupported status: $v")
}
internal val AccountStatus.Payment.tariffPlan: TangemPayCustomerTariffPlan?
get() = when (val v = value) {
is PaymentAccountStatusValue.Inactive -> v.tariffPlan.tariff
is PaymentAccountStatusValue.AwaitingPlanSelection -> v.tariffPlan
is PaymentAccountStatusValue.Loaded -> v.tariffPlan?.tariff
is PaymentAccountStatusValue.Deactivated -> null
else -> error("TangemPayDetails opened with unsupported status: $v")
}
internal val AccountStatus.Payment.isDeactivated: Boolean
get() = value is PaymentAccountStatusValue.Deactivated

View file

@ -7,6 +7,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
@ -89,7 +90,10 @@ internal class TangemPayDetailsModelTest {
every { userWalletId } returns this@TangemPayDetailsModelTest.userWalletId
}
}
val params = TangemPayDetailsContainerComponent.Params(initialStatus = paymentStatus)
val params = TangemPayDetailsContainerComponent.Params(
initialStatus = paymentStatus,
initialRoute = TangemPayDetailsInitialRoute.ACCOUNT_DETAILS,
)
every { paymentAccountStatusSupplier.invoke(any<UserWalletId>()) } returns flowOf(paymentStatus)
every { cardDetailsRepository.cardFrozenState(any()) } returns flowOf(frozenState)

View file

@ -14,11 +14,13 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.WalletSelectorListener
import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButtonLoadingTransformer
@ -46,6 +48,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
private val urlOpener: UrlOpener,
private val eligibilityManager: TangemPayEligibilityManager,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
) : Model(), WalletSelectorListener {
private val params = paramsContainer.require<TangemPayOnboardingComponent.Params>()
@ -118,7 +121,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
.onRight { customerInfo ->
when {
customerInfo.kycStatus != KycStatus.APPROVED -> {
if (customerInfo.productInstance == null) {
if (shouldCreateOrderBeforeKyc(customerInfo)) {
repository.createOrder(userWalletId)
.onLeft { error ->
TangemLogger.e("Error creating order before KYC: $error")
@ -209,7 +212,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
if (customerInfo.kycStatus == KycStatus.APPROVED) {
back()
} else {
if (customerInfo.productInstance == null) {
if (shouldCreateOrderBeforeKyc(customerInfo)) {
repository.createOrder(userWalletId)
.onLeft { error ->
TangemLogger.e("Error creating order before KYC: $error")
@ -222,6 +225,9 @@ internal class TangemPayOnboardingModel @Inject constructor(
}
}
private fun shouldCreateOrderBeforeKyc(customerInfo: CustomerInfo): Boolean =
!tangemPayFeatureToggles.isTiersPlusPlanEnabled && customerInfo.productInstance == null
private fun openKyc(userWalletId: UserWalletId) {
router.replaceAll(
AppRoute.Wallet,

View file

@ -9,17 +9,23 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.common.routing.AppRoute
import com.tangem.core.error.UniversalError
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
@ -33,6 +39,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayOnboardingModelTest {
@ -43,12 +50,14 @@ internal class TangemPayOnboardingModelTest {
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase = mockk(relaxed = true)
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk()
private val deeplink = "tangem://onboard-visa"
@BeforeEach
fun resetMocks() {
clearMocks(router, repository, eligibilityManager, analytics, produceInitialDataUseCase, urlOpener)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
}
@ParameterizedTest
@ -166,6 +175,61 @@ internal class TangemPayOnboardingModelTest {
model.onDestroy()
}
@Test
fun `GIVEN tiers off and KYC not approved WHEN onboarding starts THEN order created before KYC`() = runTest {
// Arrange
val userWalletId = UserWalletId("011")
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
coEvery { produceInitialDataUseCase(userWalletId) } returns Unit.right()
coEvery { repository.getCustomerInfo(userWalletId) } returns
buildCustomerInfo(kycStatus = KycStatus.PENDING).right()
coEvery { repository.createOrder(userWalletId) } returns "order_1".right()
// Act
val model = createModel(TangemPayOnboardingComponent.Params.HotWalletOnboarding(userWalletId))
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { repository.createOrder(userWalletId) }
verify(exactly = 1) {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc(userWalletId = userWalletId), onComplete = any())
}
model.onDestroy()
}
@Test
fun `GIVEN tiers on and KYC not approved WHEN onboarding starts THEN order not created before KYC`() = runTest {
// Arrange
val userWalletId = UserWalletId("011")
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
coEvery { produceInitialDataUseCase(userWalletId) } returns Unit.right()
coEvery { repository.getCustomerInfo(userWalletId) } returns
buildCustomerInfo(kycStatus = KycStatus.PENDING).right()
// Act
val model = createModel(TangemPayOnboardingComponent.Params.HotWalletOnboarding(userWalletId))
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { repository.createOrder(any()) }
verify(exactly = 1) {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc(userWalletId = userWalletId), onComplete = any())
}
model.onDestroy()
}
private fun buildCustomerInfo(kycStatus: KycStatus) = CustomerInfo(
customerId = "cust_1",
productInstances = emptyList(),
cards = emptyList(),
kycStatus = kycStatus,
state = CustomerInfo.State.NEW,
fiatBalance = null,
cryptoBalance = null,
availableForWithdrawal = BigDecimal.ZERO,
tariffPlan = null,
)
private fun TestScope.createModel(params: TangemPayOnboardingComponent.Params): TangemPayOnboardingModel {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TangemPayOnboardingModel(
@ -183,6 +247,7 @@ internal class TangemPayOnboardingModelTest {
produceInitialDataUseCase = produceInitialDataUseCase,
urlOpener = urlOpener,
eligibilityManager = eligibilityManager,
tangemPayFeatureToggles = tangemPayFeatureToggles,
)
}

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -159,7 +160,11 @@ internal class DefaultWalletRouter @Inject constructor(
}
override fun openTangemPaySelectPlan(status: AccountStatus.Payment) {
// TODO v_rodionov: [REDACTED_TASK_KEY] Tiers Onboarding - part 2
val route = AppRoute.TangemPayDetails(
status = status,
initialRoute = TangemPayDetailsInitialRoute.SELECT_PLAN,
)
router.push(route)
}
override fun openYieldSupplyBottomSheet(