Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 21:18:17 +05:00
parent 53bde31f89
commit 7443421bb1
20 changed files with 852 additions and 77 deletions

View file

@ -5,7 +5,6 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -119,7 +118,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
},
error = null,
virtualAccount = VirtualAccountOnramp.None,
virtualAccount = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,

View file

@ -396,11 +396,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
* Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle.
* If a product instance with [SpecificationDataType.ACCOUNT] exists, eagerly fetches its bank credentials
* ([VirtualAccountOnramp.Available]); otherwise surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else
* [VirtualAccountOnramp.None].
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
*/
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp {
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return VirtualAccountOnramp.None
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null
val accountInstance = productInstances.firstOrNull {
it.specificationDataType == SpecificationDataType.ACCOUNT
@ -409,7 +408,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
ifLeft = { error ->
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
VirtualAccountOnramp.None
null
},
ifRight = { credentials ->
VirtualAccountOnramp.Available(
@ -423,13 +422,13 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
ifLeft = { error ->
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")
VirtualAccountOnramp.None
null
},
ifRight = { channels ->
if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) {
VirtualAccountOnramp.Eligible
} else {
VirtualAccountOnramp.None
null
}
},
)

View file

@ -0,0 +1,303 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import arrow.core.left
import com.google.common.truth.Truth.assertThat
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.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.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.repository.*
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultPaymentAccountStatusFetcherTest {
private val paymentAccountStatusesStore: PaymentAccountStatusesStore = mockk(relaxed = true)
private val onboardingRepository: OnboardingRepository = mockk()
private val customerOrderRepository: CustomerOrderRepository = mockk()
private val deviceSecurity: DeviceSecurityInfoProvider = mockk(relaxed = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk()
private val eligibilityManager: TangemPayEligibilityManager = mockk(relaxed = true)
private val reissueCardRepository: TangemPayReissueCardRepository = mockk()
private val singleQuoteSupplier: SingleQuoteStatusSupplier = mockk()
private val closeCardRepository: TangemPayCloseCardRepository = mockk()
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk()
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
private val fetcher = DefaultPaymentAccountStatusFetcher(
paymentAccountStatusesStore = paymentAccountStatusesStore,
onboardingRepository = onboardingRepository,
customerOrderRepository = customerOrderRepository,
deviceSecurity = deviceSecurity,
dispatchers = dispatchers,
tangemPayCurrencyFactory = tangemPayCurrencyFactory,
eligibilityManager = eligibilityManager,
reissueCardRepository = reissueCardRepository,
singleQuoteSupplier = singleQuoteSupplier,
closeCardRepository = closeCardRepository,
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
)
private val userWalletId = UserWalletId("011")
private val params = PaymentAccountStatusFetcher.Params(userWalletId)
private val bankCredentialsFixture = BankCredentials(
type = "ACH",
beneficiaryName = "Test Beneficiary",
beneficiaryAddress = "123 Main St",
beneficiaryBankName = "Test Bank",
beneficiaryBankAddress = "456 Bank Ave",
accountNumber = "1234567890",
routingNumber = "021000021",
)
private val cardProductInstance = CustomerInfo.ProductInstance(
id = "pi_card",
cardId = "card_1",
frozenState = TangemPayCardFrozenState.Unfrozen,
displayName = null,
actualCardLimit = null,
adminCardLimit = null,
status = CustomerInfo.ProductInstance.Status.ACTIVE,
specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.CARD,
)
private val accountProductInstance = CustomerInfo.ProductInstance(
id = "pi_account",
cardId = "",
frozenState = TangemPayCardFrozenState.Unfrozen,
displayName = null,
actualCardLimit = null,
adminCardLimit = null,
status = CustomerInfo.ProductInstance.Status.ACTIVE,
specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.ACCOUNT,
)
private val cardInfo = CustomerInfo.CardInfo(
cardId = "card_1",
cardStatus = TangemPayCard.Status.ACTIVE,
lastFourDigits = "1234",
isPinSet = true,
)
private fun buildCustomerInfo(
productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance),
) = CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.TEN,
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "usdc",
chainId = 137L,
depositAddress = "0xdeposit",
tokenContractAddress = "0xcontract",
balance = BigDecimal.TEN,
),
availableForWithdrawal = BigDecimal.TEN,
cards = listOf(cardInfo),
productInstances = productInstances,
tariffPlan = null,
)
@BeforeEach
fun setUp() {
clearMocks(
onboardingRepository,
customerOrderRepository,
tangemPayCurrencyFactory,
reissueCardRepository,
singleQuoteSupplier,
closeCardRepository,
cardDetailsRepository,
issueCardRepository,
virtualAccountFeatureToggles,
)
// Relaxed mocks don't need clearing — deviceSecurity, eligibilityManager, paymentAccountStatusesStore
// are relaxed and consistent with their relaxed defaults (false, empty, etc.)
clearMocks(paymentAccountStatusesStore, answers = false)
}
/**
* Stubs the full happy-path chain up to [CustomerInfo.convertToContentState] so the fetcher
* can produce a [PaymentAccountStatusValue.Loaded] result. Only the [customerInfo] parameter is
* varied per test to exercise different VA on-ramp branches.
*/
private suspend fun stubHappyPath(customerInfo: CustomerInfo) {
val token: CryptoCurrency.Token = mockk(relaxed = true)
coEvery { onboardingRepository.hasTangemPayInWallet(userWalletId) } returns Either.Right(true)
coEvery { onboardingRepository.isTangemPayInitialDataProduced(userWalletId) } returns true
coEvery { onboardingRepository.getOrderId(userWalletId) } returns null
coEvery { onboardingRepository.getCustomerInfo(userWalletId) } returns Either.Right(customerInfo)
coEvery { paymentAccountStatusesStore.getSyncOrNull(userWalletId) } returns null
coEvery { paymentAccountStatusesStore.store(any(), any()) } just Runs
every { tangemPayCurrencyFactory.create(userWalletId) } returns token
coEvery { singleQuoteSupplier.getSyncOrNull(any()) } returns null
coEvery { cardDetailsRepository.cardFrozenStateSync(any()) } returns TangemPayCardFrozenState.Unfrozen
coEvery { closeCardRepository.getCloseOrderId(any(), any()) } returns Either.Right(null)
coEvery { reissueCardRepository.getReissueOrderId(any(), any()) } returns Either.Right(null)
coEvery { issueCardRepository.getIssueOrderIds(any()) } returns emptyList()
}
/** Collects all [AccountStatus.Payment] values stored via [PaymentAccountStatusesStore.store]. */
private fun captureStoredStatuses(): MutableList<AccountStatus.Payment> {
val captured = mutableListOf<AccountStatus.Payment>()
coEvery { paymentAccountStatusesStore.store(any(), capture(captured)) } just Runs
return captured
}
private fun MutableList<AccountStatus.Payment>.lastLoaded(): PaymentAccountStatusValue.Loaded {
val loaded = filterIsInstance<AccountStatus.Payment>()
.map { it.value }
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
.lastOrNull()
return requireNotNull(loaded) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `resolveVirtualAccountOnramp` {
@Test
fun `GIVEN feature toggle is off WHEN invoke THEN virtualAccount is null`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
}
@Test
fun `GIVEN toggle on and ACCOUNT instance with bank credentials WHEN invoke THEN virtualAccount is Available`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = listOf(cardProductInstance, accountProductInstance),
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns Either.Right(bankCredentialsFixture)
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isEqualTo(
VirtualAccountOnramp.Available(
productInstanceId = "pi_account",
bankCredentials = bankCredentialsFixture,
),
)
}
@Test
fun `GIVEN toggle on and ACCOUNT instance but bank credentials fetch fails WHEN invoke THEN virtualAccount is null`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = listOf(cardProductInstance, accountProductInstance),
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
}
@Test
fun `GIVEN toggle on and no ACCOUNT instance and customer is eligible WHEN invoke THEN virtualAccount is Eligible`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
}
@Test
fun `GIVEN toggle on and no ACCOUNT instance and eligibility fetch fails WHEN invoke THEN virtualAccount is null`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
}
}
}