Updated on 2026-08-14
This commit is contained in:
parent
370d902dad
commit
9efe573323
22 changed files with 360 additions and 39 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ interface OnboardingRepository {
|
|||
suspend fun createVirtualAccountOrder(
|
||||
userWalletId: UserWalletId,
|
||||
paymentAccountAddress: String,
|
||||
idempotencyKey: String,
|
||||
): Either<VisaApiError, String>
|
||||
|
||||
suspend fun getVirtualAccountOrderId(userWalletId: UserWalletId): String?
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ internal sealed class TangemPayCardNavigation {
|
|||
@Serializable
|
||||
data class VirtualAccountDeposit(
|
||||
val virtualAccountOnramp: VirtualAccountOnramp,
|
||||
val userWalletId: UserWalletId,
|
||||
val paymentAccountAddress: String,
|
||||
) : TangemPayCardNavigation()
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ internal sealed class TangemPayDetailsNavigation {
|
|||
@Serializable
|
||||
data class VirtualAccountDeposit(
|
||||
val virtualAccountOnramp: VirtualAccountOnramp,
|
||||
val userWalletId: UserWalletId,
|
||||
val paymentAccountAddress: String,
|
||||
) : TangemPayDetailsNavigation()
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,4 +23,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
|
|||
|
||||
@Serializable
|
||||
data object SelectPlan : TangemPayAccountDetailsInnerRoute()
|
||||
|
||||
@Serializable
|
||||
data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute()
|
||||
}
|
||||
|
|
@ -27,4 +27,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route {
|
|||
|
||||
@Serializable
|
||||
data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute()
|
||||
|
||||
@Serializable
|
||||
data object VirtualAccountDepositSuccess : TangemPayCardDetailsInnerRoute()
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue