Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 10:38:00 +05:00
parent 0bd23be25f
commit 2c754243f5
16 changed files with 378 additions and 29 deletions

View file

@ -298,11 +298,13 @@ internal interface TangemPayDataModule {
fun provideCreateVirtualAccountOrderUseCase(
onboardingRepository: OnboardingRepository,
pollingUseCase: StartTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
appCoroutineScope: AppCoroutineScope,
): CreateVirtualAccountOrderUseCase {
return CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = appCoroutineScope,
)
}

View file

@ -111,6 +111,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
logger.i("invoke() end ${params.userWalletId}: isRight=${result.isRight()}")
}
override suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
paymentAccountStatusesStore.markVirtualAccountProcessing(userWalletId)
}
private suspend fun proceedHasTangemPayResult(
account: Account.Payment,
hasTangemPay: Boolean,
@ -394,8 +398,15 @@ 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
*
* Resolution order:
* 1. A product instance with [SpecificationDataType.ACCOUNT] exists clears any stale persisted VA order id
* (idempotent) and eagerly fetches its bank credentials ([VirtualAccountOnramp.Available], or
* [VirtualAccountOnramp.BankCredentialsError] on failure).
* 2. Otherwise, a VA order id is persisted locally checks its status via `getOrderData`:
* NEW/PROCESSING/COMPLETED (or a lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
* clears the persisted id and falls through to eligibility.
* 3. Otherwise (or after a CANCELED order) surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
*/
private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp? {
@ -405,6 +416,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
it.specificationDataType == SpecificationDataType.ACCOUNT
}
if (accountInstance != null) {
// Order provisioned into an ACCOUNT product instance — drop the in-flight order hint (idempotent).
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold(
ifLeft = { error ->
logger.e("getBankCredentials failed for ${accountInstance.id}: $error")
@ -419,6 +432,32 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
val vaOrderId = onboardingRepository.getVirtualAccountOrderId(userWalletId)
if (vaOrderId != null) {
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = vaOrderId).fold(
ifLeft = { error ->
logger.e("getOrderData(va) failed for $vaOrderId: $error")
VirtualAccountOnramp.Processing
},
ifRight = { orderData ->
when (orderData.status) {
OrderStatus.CANCELED -> {
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
resolveEligibility(userWalletId)
}
OrderStatus.NEW,
OrderStatus.PROCESSING,
OrderStatus.COMPLETED,
-> VirtualAccountOnramp.Processing
}
},
)
}
return resolveEligibility(userWalletId)
}
private suspend fun resolveEligibility(userWalletId: UserWalletId): VirtualAccountOnramp? {
return onboardingRepository.fetchCustomerEligibility(userWalletId).fold(
ifLeft = { error ->
logger.e("fetchCustomerEligibility failed for $userWalletId: $error")

View file

@ -200,6 +200,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearVirtualAccountOrderId(customerWalletAddress)
}
}
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
?: error("no userWallet found")

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
@ -89,6 +90,28 @@ internal class PaymentAccountStatusesStore(
}
}
/**
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
* read-modify-write atomically inside [RuntimeSharedStore.update] to avoid a lost update racing with a
* concurrent [store]/[updateStatusSource] call. No-op (no write) when there is no cached entry for
* [userWalletId], or when its value isn't [PaymentAccountStatusValue.Loaded]. Not persisted, mirroring
* [updateStatusSource].
*/
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId) {
logger.i("markVirtualAccountProcessing($userWalletId)")
runtimeStore.update(emptyMap()) { stored ->
stored.toMutableMap().apply {
val paymentAccountStatus = this[userWalletId.stringValue] ?: return@update stored
val loaded = paymentAccountStatus.value as? PaymentAccountStatusValue.Loaded ?: return@update stored
val newValue = paymentAccountStatus.copy(
value = loaded.copy(virtualAccount = VirtualAccountOnramp.Processing),
)
put(key = userWalletId.stringValue, value = newValue)
}
}
}
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
logger.i("store($userWalletId): valueType=${status.value::class.simpleName}")
coroutineScope {

View file

@ -100,6 +100,14 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
}
override suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId) {
if (isMockMode) {
mockVaOrderIds.remove(userWalletId)
return
}
real.clearVirtualAccountOrderId(userWalletId)
}
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)

View file

@ -2,8 +2,15 @@ package com.tangem.data.pay.flow
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.store.WalletIdWithPaymentStatus
import com.tangem.data.pay.store.WalletIdWithPaymentStatusDM
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
@ -18,11 +25,15 @@ 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.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
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.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -107,9 +118,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
isPinSet = true,
)
private fun buildCustomerInfo(
productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance),
) = CustomerInfo(
private fun buildCustomerInfo(productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance)) =
CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
@ -187,7 +197,38 @@ internal class DefaultPaymentAccountStatusFetcherTest {
.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 }}" }
return requireNotNull(
loaded,
) { "Expected at least one Loaded status to be stored; stored: ${map { it.value::class.simpleName }}" }
}
/** Builds a [PaymentAccountStatusValue.Loaded] fixture with every field defaulted except [virtualAccount]. */
private fun loadedFixture(virtualAccount: VirtualAccountOnramp? = null): PaymentAccountStatusValue.Loaded {
val token: CryptoCurrency.Token = mockk(relaxed = true)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = "cust_1",
depositAddress = "0xdeposit",
balance = PaymentAccountStatusValue.Balance(
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,
),
cryptoCurrency = token,
cards = emptyList(),
fiatRate = null,
error = null,
virtualAccount = virtualAccount,
)
}
@Nested
@ -219,6 +260,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns Either.Right(bankCredentialsFixture)
@ -235,6 +277,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
bankCredentials = bankCredentialsFixture,
),
)
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
}
@Test
@ -246,6 +289,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
)
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.getBankCredentials(userWalletId, "pi_account")
} returns VisaApiError.UnknownWithoutCode.left()
@ -257,6 +301,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isEqualTo(VirtualAccountOnramp.BankCredentialsError)
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
}
@Test
@ -266,6 +311,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
@ -286,6 +332,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns VisaApiError.UnknownWithoutCode.left()
@ -298,5 +345,165 @@ internal class DefaultPaymentAccountStatusFetcherTest {
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.virtualAccount).isNull()
}
@Test
fun `GIVEN no instance and va order PROCESSING WHEN invoke THEN virtualAccount is Processing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.PROCESSING, withdrawTxHash = null).right()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va order COMPLETED but instance absent WHEN invoke THEN virtualAccount is Processing`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.COMPLETED, withdrawTxHash = null).right()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va getOrderData fails WHEN invoke THEN virtualAccount is Processing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Processing)
}
@Test
fun `GIVEN no instance and va order CANCELED WHEN invoke THEN id cleared and falls back to eligibility`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns OrderData(customerId = "c1", status = OrderStatus.CANCELED, withdrawTxHash = null).right()
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
}
}
/**
* [markVirtualAccountProcessing] now delegates entirely to the atomic
* [PaymentAccountStatusesStore.markVirtualAccountProcessing] (read-modify-write happens inside the store's
* `runtimeStore.update` lambda, see [REDACTED_TASK_KEY] review). A mocked store can't exercise that internal branching,
* so these tests wire the fetcher to a real [PaymentAccountStatusesStore] (real [RuntimeSharedStore] +
* in-memory persistence fake) and assert on its resulting state exercising the delegate wiring and the
* store's atomic logic together.
*/
@Nested
inner class MarkVirtualAccountProcessing {
private val runtimeStore = RuntimeSharedStore<WalletIdWithPaymentStatus>()
private val persistenceStore = MockStateDataStore<WalletIdWithPaymentStatusDM>(default = emptyMap())
private val converter: PaymentAccountStatusValueDMConverter = mockk(relaxed = true)
private val realStore = PaymentAccountStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
converter = converter,
scope = TestAppCoroutineScope(),
)
private val realFetcher = DefaultPaymentAccountStatusFetcher(
paymentAccountStatusesStore = realStore,
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 account = Account.Payment(userWalletId = userWalletId)
@Test
fun `GIVEN cached Loaded with eligible onramp WHEN mark THEN virtualAccount becomes Processing`() = runTest {
// Arrange
val loaded = loadedFixture(virtualAccount = VirtualAccountOnramp.Eligible)
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = loaded))
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
val updated = realStore.getSyncOrNull(userWalletId)?.value
assertThat(updated).isEqualTo(loaded.copy(virtualAccount = VirtualAccountOnramp.Processing))
}
@Test
fun `GIVEN no cached value WHEN mark THEN store stays empty`() = runTest {
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
assertThat(realStore.getSyncOrNull(userWalletId)).isNull()
}
@Test
fun `GIVEN cached non-Loaded value WHEN mark THEN value stays unchanged`() = runTest {
// Arrange
val issuingCard = PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
realStore.store(userWalletId, AccountStatus.Payment(account = account, value = issuingCard))
// Act
realFetcher.markVirtualAccountProcessing(userWalletId)
// Assert
assertThat(realStore.getSyncOrNull(userWalletId)?.value).isEqualTo(issuingCard)
}
}
}

View file

@ -22,6 +22,15 @@ sealed interface VirtualAccountOnramp {
val bankCredentials: BankCredentials,
) : VirtualAccountOnramp
/**
* A VA on-ramp order has been submitted and is being provisioned (order status NEW/PROCESSING, or
* COMPLETED before the ACCOUNT product instance appears). The bank-transfer entry point stays visible;
* tapping it shows the "Preparing your banking details" bottom sheet. Transient never persisted,
* re-resolved on the next status fetch, cleared once the ACCOUNT instance appears or the order is canceled.
*/
@Serializable
data object Processing : VirtualAccountOnramp
/**
* VA product instance exists, but its bank credentials failed to load. The bank-transfer entry point
* stays visible; tapping it surfaces a retryable "couldn't load banking details" error instead of the

View file

@ -2,6 +2,8 @@ package com.tangem.domain.pay.flow
import arrow.core.Either
import com.tangem.domain.core.flow.FlowFetcher
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.Params> {
@ -10,5 +12,12 @@ interface PaymentAccountStatusFetcher : FlowFetcher<PaymentAccountStatusFetcher.
return invoke(Params(userWalletId))
}
/**
* Optimistically marks the cached VA on-ramp as [VirtualAccountOnramp.Processing] so the UI reflects a
* cached [PaymentAccountStatusValue.Loaded].
*/
suspend fun markVirtualAccountProcessing(userWalletId: UserWalletId)
data class Params(val userWalletId: UserWalletId)
}

View file

@ -42,6 +42,8 @@ interface OnboardingRepository {
suspend fun storeVirtualAccountOrderId(userWalletId: UserWalletId, vaOrderId: String)
suspend fun clearVirtualAccountOrderId(userWalletId: UserWalletId)
suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType>

View file

@ -3,6 +3,7 @@ package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.OnboardingRepository
@ -22,6 +23,7 @@ import java.util.UUID
class CreateVirtualAccountOrderUseCase(
private val onboardingRepository: OnboardingRepository,
private val pollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(
@ -36,6 +38,9 @@ class CreateVirtualAccountOrderUseCase(
idempotencyKey = UUID.randomUUID().toString(),
).bind()
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
// Optimistically flip the cached on-ramp to Processing so the UI shows "Preparing" immediately
// (no wait for the poll/refetch to confirm).
paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId)
appCoroutineScope.launch {
pollingUseCase.invoke(
order = TangemPayOrderInfo(orderId = vaOrderId, orderStatus = OrderStatus.NEW),

View file

@ -4,6 +4,7 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
@ -17,9 +18,12 @@ internal class CreateVirtualAccountOrderUseCaseTest {
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
private val pollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxUnitFun = true)
private val useCase = CreateVirtualAccountOrderUseCase(
onboardingRepository = onboardingRepository,
pollingUseCase = pollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = TestAppCoroutineScope(),
)
@ -36,6 +40,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) }
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
}
@Test
@ -50,6 +55,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { onboardingRepository.storeVirtualAccountOrderId(userWalletId, "new-id") }
coVerify(exactly = 1) { pollingUseCase.invoke(any(), userWalletId) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.markVirtualAccountProcessing(userWalletId) }
}
@Test
@ -64,5 +70,6 @@ internal class CreateVirtualAccountOrderUseCaseTest {
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.markVirtualAccountProcessing(any()) }
}
}

View file

@ -496,6 +496,7 @@ internal class TangemPayCardPageModel @Inject constructor(
when (val onramp = loaded.virtualAccount) {
null -> return
is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError()
VirtualAccountOnramp.Processing -> showVaPreparing()
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
-> openVirtualAccountDeposit(onramp, loaded)
@ -521,6 +522,11 @@ internal class TangemPayCardPageModel @Inject constructor(
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)

View file

@ -316,6 +316,7 @@ internal class TangemPayDetailsModel @Inject constructor(
when (val onramp = loaded.virtualAccount) {
null -> return
is VirtualAccountOnramp.BankCredentialsError -> showVaBankingDetailsError()
VirtualAccountOnramp.Processing -> showVaPreparing()
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
-> openVirtualAccountDeposit(onramp, loaded)
@ -341,6 +342,11 @@ internal class TangemPayDetailsModel @Inject constructor(
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)

View file

@ -81,9 +81,11 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked())
createVirtualAccountOrder()
}
// Error onramp is intercepted before this sheet opens (a dedicated error sheet is shown instead);
// the branch only keeps the `when` exhaustive.
VirtualAccountOnramp.BankCredentialsError -> onDismiss()
// Processing/Error onramps never reach this sheet (a message/error sheet is shown instead);
// these branches only keep the `when` exhaustive.
VirtualAccountOnramp.Processing,
VirtualAccountOnramp.BankCredentialsError,
-> onDismiss()
}
}

View file

@ -177,6 +177,23 @@ internal object TangemPayMessagesFactory {
)
}
fun createVaPreparingMessage(): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_clock_24) {
type = MessageBottomSheetUM.Icon.Type.Informative
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
}
title = TextReference.Res(R.string.tangempay_bank_transfer_success_title)
body = TextReference.Res(R.string.tangempay_bank_transfer_success_subtitle)
}
secondaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {

View file

@ -25,7 +25,7 @@ internal fun BankCredentials.toRequisitesRows(): List<RequisitesRow> = listOf(
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_beneficiary_address),
titleForShare = "Beneficiary address",
value = beneficiaryBankAddress,
value = beneficiaryAddress,
),
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_bank_name),