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

@ -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)
}
}
}