diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index ae74b0c56c..67842924ad 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -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, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 6c262fdb70..fbe30d141d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -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,9 +398,16 @@ 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 `null`. + * + * 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? { if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return null @@ -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") diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index c97e31cb39..3c7090cfab 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -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") diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index c9ff2fd990..6faf08545b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -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 { diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 03b0abddb9..c54d37c768 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -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 = real.hasTangemPayInWallet(userWalletId) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt index 4c7bafec4f..178766c64b 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -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,27 +118,26 @@ internal class DefaultPaymentAccountStatusFetcherTest { isPinSet = true, ) - private fun buildCustomerInfo( - productInstances: List = 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, - ) + private fun buildCustomerInfo(productInstances: List = 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, + ) @BeforeEach fun setUp() { @@ -187,7 +197,38 @@ internal class DefaultPaymentAccountStatusFetcherTest { .map { it.value } .filterIsInstance() .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() + private val persistenceStore = MockStateDataStore(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) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt index e3ba1c80bd..c01ac5cb73 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt @@ -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 diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt index eed0daaec2..2bd70a15f9 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt @@ -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 { @@ -10,5 +12,12 @@ interface PaymentAccountStatusFetcher : FlowFetcher suspend fun checkCustomerEligibility(): List diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt index 2ad979a933..207e037cd1 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCase.kt @@ -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), diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt index 306f9da329..306f0f6fcf 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateVirtualAccountOrderUseCaseTest.kt @@ -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()) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index fea873c6c6..fbd27c82ec 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 09649cb4ee..202898ca9a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt index 6bc0a4a958..69909b584b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayVirtualAccountDepositModel.kt @@ -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() } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 55c4d6944f..7acda5692e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -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 { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt index e7d6f208b7..2426355a94 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/VirtualAccountRequisites.kt @@ -25,7 +25,7 @@ internal fun BankCredentials.toRequisitesRows(): List = 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),