Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 17:10:29 +05:00
parent 370d902dad
commit 9efe573323
22 changed files with 360 additions and 39 deletions

View file

@ -181,13 +181,14 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun createVirtualAccountOrder(
userWalletId: UserWalletId,
paymentAccountAddress: String,
idempotencyKey: String,
): Either<VisaApiError, String> = withContext(dispatcherProvider.io) {
requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.createVirtualAccountOrder(
authHeader = authHeader,
body = VirtualAccountOrderRequest(
data = VirtualAccountOrderRequest.Data(depositAddress = paymentAccountAddress),
idempotencyKey = UUID.randomUUID().toString(),
idempotencyKey = idempotencyKey,
),
)
}.map { response -> requireNotNull(response.result).id }

View file

@ -35,6 +35,7 @@ interface OnboardingRepository {
suspend fun createVirtualAccountOrder(
userWalletId: UserWalletId,
paymentAccountAddress: String,
idempotencyKey: String,
): Either<VisaApiError, String>
suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String?

View file

@ -7,6 +7,7 @@ import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import java.util.UUID
/**
* Creates the Virtual Account on-ramp order (VA MVP0, TWI-1638) and persists the returned id as `vaOrderId`.
@ -29,6 +30,7 @@ class CreateVirtualAccountOrderUseCase(
val vaOrderId = onboardingRepository.createVirtualAccountOrder(
userWalletId = userWalletId,
paymentAccountAddress = paymentAccountAddress,
idempotencyKey = UUID.randomUUID().toString(),
).bind()
onboardingRepository.storeVirtualAccountOrderId(userWalletId = userWalletId, vaOrderId = vaOrderId)
pollingUseCase.invoke(

View file

@ -28,7 +28,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
val result = useCase(userWalletId, paymentAccountAddress)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any()) }
coVerify(exactly = 0) { onboardingRepository.createVirtualAccountOrder(any(), any(), any()) }
coVerify(exactly = 0) { onboardingRepository.storeVirtualAccountOrderId(any(), any()) }
coVerify(exactly = 0) { pollingUseCase.invoke(any(), any()) }
}
@ -37,7 +37,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
fun `GIVEN no stored id and create succeeds WHEN invoke THEN stores id and starts polling`() = runTest {
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress)
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any())
} returns "new-id".right()
val result = useCase(userWalletId, paymentAccountAddress)
@ -51,7 +51,7 @@ internal class CreateVirtualAccountOrderUseCaseTest {
fun `GIVEN no stored id and create fails WHEN invoke THEN returns error and does not store or poll`() = runTest {
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns null
coEvery {
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress)
onboardingRepository.createVirtualAccountOrder(userWalletId, paymentAccountAddress, any())
} returns VisaApiError.Unspecified.left()
val result = useCase(userWalletId, paymentAccountAddress)

View file

@ -101,6 +101,10 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
userWalletId = params.initialStatus.userWalletId,
),
)
TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess ->
TangemPayVirtualAccountDepositSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
}
private fun onChildBack() {

View file

@ -112,6 +112,10 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
isRedesignEnabled = tangemPayFeatureToggles.isRedesignEnabled,
)
TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess ->
TangemPayVirtualAccountDepositSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
}
private fun onChildBack() {

View file

@ -63,6 +63,7 @@ internal class TangemPayCardPageScreenComponent(
}
}
@Suppress("LongMethod")
private fun bottomSheetChild(
navigation: TangemPayCardNavigation,
componentContext: ComponentContext,
@ -109,8 +110,11 @@ internal class TangemPayCardPageScreenComponent(
appComponentContext = context,
params = TangemPayVirtualAccountDepositComponent.Params(
virtualAccountOnramp = navigation.virtualAccountOnramp,
userWalletId = navigation.userWalletId,
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
is TangemPayCardNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create(

View file

@ -116,6 +116,7 @@ internal class TangemPayDetailsComponent(
}
}
@Suppress("LongMethod")
private fun bottomSheetChild(
navigation: TangemPayDetailsNavigation,
componentContext: ComponentContext,
@ -155,8 +156,11 @@ internal class TangemPayDetailsComponent(
appComponentContext = context,
params = TangemPayVirtualAccountDepositComponent.Params(
virtualAccountOnramp = navigation.virtualAccountOnramp,
userWalletId = navigation.userWalletId,
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
is TangemPayDetailsNavigation.VirtualAccountRequisites -> virtualAccountAddFundsComponentFactory.create(

View file

@ -1,10 +1,13 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.model.TangemPayVirtualAccountDepositModel
import com.tangem.features.tangempay.ui.TangemPayVirtualAccountDepositBottomSheet
@ -25,12 +28,16 @@ internal class TangemPayVirtualAccountDepositComponent(
@Composable
override fun BottomSheet() {
TangemPayVirtualAccountDepositBottomSheet(state = model.uiState)
val state by model.uiState.collectAsStateWithLifecycle()
TangemPayVirtualAccountDepositBottomSheet(state = state)
}
data class Params(
val virtualAccountOnramp: VirtualAccountOnramp,
val userWalletId: UserWalletId,
val paymentAccountAddress: String,
val onDismiss: () -> Unit,
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
val onOrderCreated: () -> Unit,
)
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.ui.components.TangemPaySuccessScreenWrapper
/**
* "Preparing your banking details". Close pops back to the previous screen.
*/
internal class TangemPayVirtualAccountDepositSuccessComponent(
private val appComponentContext: AppComponentContext,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = ::onClose)
TangemPaySuccessScreenWrapper(
modifier = modifier,
title = resourceReference(R.string.tangempay_bank_transfer_success_title),
subtitle = resourceReference(R.string.tangempay_bank_transfer_success_subtitle),
buttonText = resourceReference(R.string.common_close),
onButtonClick = ::onClose,
)
}
private fun onClose() {
router.pop()
}
}

View file

@ -38,6 +38,8 @@ internal sealed class TangemPayCardNavigation {
@Serializable
data class VirtualAccountDeposit(
val virtualAccountOnramp: VirtualAccountOnramp,
val userWalletId: UserWalletId,
val paymentAccountAddress: String,
) : TangemPayCardNavigation()
@Serializable

View file

@ -29,6 +29,8 @@ internal sealed class TangemPayDetailsNavigation {
@Serializable
data class VirtualAccountDeposit(
val virtualAccountOnramp: VirtualAccountOnramp,
val userWalletId: UserWalletId,
val paymentAccountAddress: String,
) : TangemPayDetailsNavigation()
@Serializable

View file

@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
internal data class TangemPayVirtualAccountDepositUM(
val fees: ImmutableList<FeeRow>,
val shouldShowTermsAndConditions: Boolean,
val isLoading: Boolean,
val onShowDetailsClick: () -> Unit,
val onDismiss: () -> Unit,
val onTermsClick: () -> Unit,

View file

@ -487,9 +487,21 @@ internal class TangemPayCardPageModel @Inject constructor(
}
override fun onClickBankTransfer() {
val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(TangemPayCardNavigation.VirtualAccountDeposit(onramp))
bottomSheetNavigation.activate(
TangemPayCardNavigation.VirtualAccountDeposit(
virtualAccountOnramp = onramp,
userWalletId = userWalletId,
paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress,
),
)
}
fun onVirtualAccountOrderCreated() {
bottomSheetNavigation.dismiss()
router.push(TangemPayCardDetailsInnerRoute.VirtualAccountDepositSuccess)
}
fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) {

View file

@ -354,9 +354,21 @@ internal class TangemPayDetailsModel @Inject constructor(
}
override fun onClickBankTransfer() {
val onramp = currentStatus.value.ifLoadedOrNull { it.virtualAccount } ?: return
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(TangemPayDetailsNavigation.VirtualAccountDeposit(onramp))
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.VirtualAccountDeposit(
virtualAccountOnramp = onramp,
userWalletId = userWalletId,
paymentAccountAddress = loaded.balance.cryptoBalance.depositAddress,
),
)
}
fun onVirtualAccountOrderCreated() {
bottomSheetNavigation.dismiss()
router.push(TangemPayAccountDetailsInnerRoute.VirtualAccountDepositSuccess)
}
fun onShowVirtualAccountRequisites(onramp: VirtualAccountOnramp.Available) {

View file

@ -4,13 +4,21 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase
import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ -19,21 +27,33 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val uiMessageSender: UiMessageSender,
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase,
) : Model() {
private val params = paramsContainer.require<TangemPayVirtualAccountDepositComponent.Params>()
val uiState: TangemPayVirtualAccountDepositUM = TangemPayVirtualAccountDepositUM(
fees = persistentListOf(
TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"),
TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"),
),
shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible,
onShowDetailsClick = ::onShowDetailsClick,
onDismiss = ::onDismiss,
onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) },
onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) },
)
val uiState: StateFlow<TangemPayVirtualAccountDepositUM>
field = MutableStateFlow(
TangemPayVirtualAccountDepositUM(
fees = persistentListOf(
TangemPayVirtualAccountDepositUM.FeeRow(
title = resourceReference(R.string.tangempay_bank_transfer_fee_ach),
value = "$1",
),
TangemPayVirtualAccountDepositUM.FeeRow(
title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire),
value = "$11",
),
),
shouldShowTermsAndConditions = params.virtualAccountOnramp is VirtualAccountOnramp.Eligible,
isLoading = false,
onShowDetailsClick = ::onShowDetailsClick,
onDismiss = ::onDismiss,
onTermsClick = { urlOpener.openUrl(TERMS_OF_USE_URL) },
onPrivacyClick = { urlOpener.openUrl(PRIVACY_POLICY_URL) },
),
)
fun onDismiss() {
params.onDismiss()
@ -42,7 +62,27 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
private fun onShowDetailsClick() {
when (params.virtualAccountOnramp) {
is VirtualAccountOnramp.Available -> params.onShowDetails(params.virtualAccountOnramp)
VirtualAccountOnramp.Eligible -> TODO()
VirtualAccountOnramp.Eligible -> createVirtualAccountOrder()
}
}
private fun createVirtualAccountOrder() {
if (uiState.value.isLoading) return
uiState.update { it.copy(isLoading = true) }
modelScope.launch {
createVirtualAccountOrderUseCase(
userWalletId = params.userWalletId,
paymentAccountAddress = params.paymentAccountAddress,
).fold(
ifLeft = {
uiState.update { state -> state.copy(isLoading = false) }
uiMessageSender.send(ToastMessage(resourceReference(R.string.common_unknown_error)))
},
ifRight = {
uiState.update { state -> state.copy(isLoading = false) }
params.onOrderCreated()
},
)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_card_20
@ -75,8 +74,8 @@ internal class TangemPayAddFundsUMConverter(
imageVector = Icons.ic_sign_usd_20,
tintReference = { TangemTheme.colors3.icon.brand },
),
title = stringReference("Bank transfer"),
description = stringReference("Receive fiat USD via ACH/FedWire"),
title = resourceReference(R.string.tangempay_topup_bank_transfer_title),
description = resourceReference(R.string.tangempay_topup_bank_transfer_body),
onClick = listener::onClickBankTransfer,
)
},

View file

@ -23,4 +23,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
@Serializable
data object SelectPlan : TangemPayAccountDetailsInnerRoute()
@Serializable
data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute()
}

View file

@ -27,4 +27,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route {
@Serializable
data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute()
@Serializable
data object VirtualAccountDepositSuccess : TangemPayCardDetailsInnerRoute()
}

View file

@ -33,12 +33,15 @@ import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_info_24
import com.tangem.core.ui.res.generated.icons.ic_sign_usd_32
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayVirtualAccountDepositUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -76,11 +79,11 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
) {
IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4))
TitleText(
text = stringReference("Bank transfer might take 1-2 business days"),
text = resourceReference(R.string.tangempay_bank_transfer_intro_title),
modifier = Modifier.padding(top = TangemTheme.dimens2.x8),
)
SubtitleText(
text = stringReference("Received USD will be converted to USDC by 1:1 rate"),
text = resourceReference(R.string.tangempay_bank_transfer_intro_subtitle),
modifier = Modifier.padding(top = TangemTheme.dimens2.x2),
)
FeesBlock(
@ -88,16 +91,18 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
modifier = Modifier.padding(top = TangemTheme.dimens2.x6),
)
InfoNotification(
text = stringReference("Deposit via ACH or FedWire only. SWIFT transfers will be returned."),
text = resourceReference(R.string.tangempay_bank_transfer_swift_warning),
modifier = Modifier.padding(top = TangemTheme.dimens2.x4),
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x4),
text = stringReference("Show details"),
text = resourceReference(R.string.tangempay_bank_transfer_show_details),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
isLoading = state.isLoading,
isEnabled = !state.isLoading,
onClick = state.onShowDetailsClick,
)
if (state.shouldShowTermsAndConditions) {
@ -118,7 +123,7 @@ private fun FeesBlock(fees: ImmutableList<TangemPayVirtualAccountDepositUM.FeeRo
start = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x2,
),
text = stringReference("Fee for onramp").resolveReference(),
text = stringResourceSafe(R.string.tangempay_bank_transfer_fee_header),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
@ -161,15 +166,31 @@ private fun InfoNotification(text: TextReference, modifier: Modifier = Modifier)
@Composable
private fun TermsFooter(onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, modifier: Modifier = Modifier) {
val linkStyle = SpanStyle(color = TangemTheme.colors3.text.primary)
val termsTitle = stringResourceSafe(R.string.common_terms_of_use)
val privacyTitle = stringResourceSafe(R.string.common_privacy_policy)
val fullText = stringResourceSafe(R.string.tangempay_bank_transfer_legal, termsTitle, privacyTitle)
// Locate each link title in the resolved (localized) string and splice them in appearance order.
// Handles translations that reorder the %1$s/%2$s placeholders and skips a title that a translation
// does not contain verbatim — falling back to plain text instead of crashing on an invalid substring range.
val links = listOf(
Triple(fullText.indexOf(termsTitle), termsTitle, onTermsClick),
Triple(fullText.indexOf(privacyTitle), privacyTitle, onPrivacyClick),
)
.filter { it.first >= 0 }
.sortedBy { it.first }
val text = buildAnnotatedString {
append("By using service, you agree with provider ")
withLink(LinkAnnotation.Clickable(tag = "terms", linkInteractionListener = { onTermsClick() })) {
withStyle(linkStyle) { append("Terms of Use") }
}
append(" and ")
withLink(LinkAnnotation.Clickable(tag = "privacy", linkInteractionListener = { onPrivacyClick() })) {
withStyle(linkStyle) { append("Privacy Policy") }
var cursor = 0
links.forEach { (index, title, onClick) ->
if (index < cursor) return@forEach
append(fullText.substring(cursor, index))
withLink(LinkAnnotation.Clickable(tag = title, linkInteractionListener = { onClick() })) {
withStyle(linkStyle) { append(title) }
}
cursor = index + title.length
}
append(fullText.substring(cursor))
}
Text(
modifier = modifier.fillMaxWidth(),
@ -267,10 +288,17 @@ private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) {
private fun previewState(shouldShowTermsAndConditions: Boolean) = TangemPayVirtualAccountDepositUM(
fees = persistentListOf(
TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("ACH"), value = "$1"),
TangemPayVirtualAccountDepositUM.FeeRow(title = stringReference("FedWire"), value = "$11"),
TangemPayVirtualAccountDepositUM.FeeRow(
title = resourceReference(R.string.tangempay_bank_transfer_fee_ach),
value = "$1",
),
TangemPayVirtualAccountDepositUM.FeeRow(
title = resourceReference(R.string.tangempay_bank_transfer_fee_fedwire),
value = "$11",
),
),
shouldShowTermsAndConditions = shouldShowTermsAndConditions,
isLoading = false,
onShowDetailsClick = {},
onDismiss = {},
onTermsClick = {},

View file

@ -0,0 +1,155 @@
package com.tangem.features.tangempay.model
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.CreateVirtualAccountOrderUseCase
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.components.TangemPayVirtualAccountDepositComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class TangemPayVirtualAccountDepositModelTest {
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountAddress = "0xcollateral"
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk()
private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true)
private val onOrderCreated: () -> Unit = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender)
}
@Test
fun `GIVEN available WHEN show details THEN opens requisites and does not create order`() = runTest {
// Arrange
val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = bankCredentials())
val model = createModel(onramp)
// Act
model.uiState.value.onShowDetailsClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { onShowDetails(onramp) }
coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) }
}
@Test
fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest {
// Arrange
coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } returns Unit.right()
val model = createModel(VirtualAccountOnramp.Eligible)
// Act
model.uiState.value.onShowDetailsClick()
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) }
verify(exactly = 1) { onOrderCreated() }
assertThat(model.uiState.value.isLoading).isFalse()
}
@Test
fun `GIVEN eligible and create fails WHEN show details THEN toast shown and loading reset`() = runTest {
// Arrange
coEvery {
createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress)
} returns VisaApiError.Unspecified.left()
val model = createModel(VirtualAccountOnramp.Eligible)
// Act
model.uiState.value.onShowDetailsClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { uiMessageSender.send(any<ToastMessage>()) }
verify { onOrderCreated wasNot Called }
assertThat(model.uiState.value.isLoading).isFalse()
}
@Test
fun `GIVEN already loading WHEN show details twice THEN use case invoked once`() = runTest {
// Arrange
val pending = CompletableDeferred<Either<VisaApiError, Unit>>()
coEvery { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) } coAnswers { pending.await() }
val model = createModel(VirtualAccountOnramp.Eligible)
// Act
model.uiState.value.onShowDetailsClick() // starts loading, use case suspends
advanceUntilIdle()
model.uiState.value.onShowDetailsClick() // gated by isLoading — must be ignored
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.isLoading).isTrue()
coVerify(exactly = 1) { createVirtualAccountOrderUseCase(userWalletId, paymentAccountAddress) }
pending.complete(Unit.right()) // let the in-flight call finish cleanly
advanceUntilIdle()
}
private fun TestScope.createModel(onramp: VirtualAccountOnramp) = TangemPayVirtualAccountDepositModel(
paramsContainer = MutableParamsContainer(
TangemPayVirtualAccountDepositComponent.Params(
virtualAccountOnramp = onramp,
userWalletId = userWalletId,
paymentAccountAddress = paymentAccountAddress,
onDismiss = {},
onShowDetails = onShowDetails,
onOrderCreated = onOrderCreated,
),
),
dispatchers = createTestingCoroutineDispatcherProvider(),
urlOpener = urlOpener,
uiMessageSender = uiMessageSender,
createVirtualAccountOrderUseCase = createVirtualAccountOrderUseCase,
)
private fun bankCredentials() = BankCredentials(
type = "ACH",
beneficiaryName = "Test Beneficiary",
beneficiaryAddress = "Addr",
beneficiaryBankName = "Bank",
beneficiaryBankAddress = "Bank Addr",
accountNumber = "123",
routingNumber = "456",
)
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -2,12 +2,14 @@ package com.tangem.features.virtualaccount.details.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
interface VirtualAccountMainComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val virtualAccountOnramp: VirtualAccountOnramp.Available,
)
interface Factory : ComponentFactory<Params, VirtualAccountMainComponent>