Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -21,11 +21,6 @@ android {
}
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -84,7 +79,5 @@ dependencies {
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -5,7 +5,6 @@
<ID>MaxChainedCallsOnSameLine:DefaultVisaRepository.kt$DefaultVisaRepository$userWallet.requireColdWallet().scanResponse.card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }</ID>
<ID>MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCardWallet( request = request, hashToSign = it.result.hash, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCustomerWallet( request = request, hashToSign = it.result.hash, ) }</ID>
<ID>MultilineLambdaItParameter:VisaApiRequestMaker.kt$VisaApiRequestMaker${ if (it is ApiResponseError.HttpException &amp;&amp; it.code == ApiResponseError.HttpException.Code.UNAUTHORIZED ) { userWalletsStore.update(userWalletId) { userWallet -&gt; userWallet.requireColdWallet().copy( scanResponse = userWallet.scanResponse.copy( // visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired, ), ) } } throw RefreshTokenExpiredException() }</ID>
<ID>MultilineLambdaItParameter:VisaTxDetailsFactory.kt$VisaTxDetailsFactory${ when (val txUrl = walletBlockchain.getExploreTxUrl(it)) { is TxExploreState.Url -&gt; txUrl.url is TxExploreState.Unsupported -&gt; "" } }</ID>
<ID>MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[cardPublicKey] = this[cardPublicKey].orEmpty() + response.transactions } }</ID>
<ID>MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[offset] = response.transactions.map(VisaTxHistoryItemConverter::convert) } }</ID>
@ -13,8 +12,6 @@
<ID>NoNameShadowing:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository$responseError</ID>
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (config != null) return@withLock requireNotNull(config)</ID>
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (provider != null) return@withLock requireNotNull(provider)</ID>
<ID>NullableToStringCall:DefaultOnboardingRepository.kt$DefaultOnboardingRepository$${error.message}</ID>
<ID>RedundantSuspendModifier:DefaultVisaRepository.kt$DefaultVisaRepository$suspend</ID>
<ID>SuspendFunSwallowedCancellation:DefaultVisaRepository.kt$DefaultVisaRepository$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:VisaApiRequestMaker.kt$VisaApiRequestMaker$runCatching</ID>
<ID>UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$if (status is VisaCardActivationStatus.RefreshTokenExpired) { throw RefreshTokenExpiredException() }</ID>

View file

@ -5,10 +5,7 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
@ -37,22 +34,24 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard()
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
customerId = value.customerId,
currencyCode = value.currencyCode,
currencyCode = value.balance.fiatBalance.currency,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal,
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard(
id = card.id,
productInstanceId = card.productInstanceId,
cardStatus = card.cardStatus.name,
hasPinCode = card.hasPinCode,
displayName = card.displayName?.value,
actualDailyLimit = card.limit?.actualCardLimit?.amount,
adminDailyLimit = card.limit?.adminCardLimit?.amount,
isFrozen = card.isFrozen,
frozenState = card.frozenState.toString(),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = card.state.toString(),
)
},
)
@ -61,9 +60,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = value.customerId,
fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
@ -88,16 +89,19 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
source = StatusSource.CACHE,
customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
TangemPayCard(
id = card.id,
productInstanceId = card.productInstanceId,
cardStatus = TangemPayCard.Status.fromString(card.cardStatus),
hasPinCode = card.hasPinCode,
displayName = card.displayName?.let { CardDisplayName(it).getOrElse { null } },
limit = TangemPayCardLimitData(
@ -108,11 +112,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY)
},
),
isFrozen = card.isFrozen,
frozenState = TangemPayCardFrozenState.fromString(card.frozenState),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = TangemPayCardState.fromString(card.state),
)
},
error = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,
@ -121,10 +126,15 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
customerId = value.customerId,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
error = null,
)
null -> PaymentAccountStatusValue.Error.Unavailable
}

View file

@ -15,6 +15,7 @@ import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawWithSwapUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
@ -30,6 +31,7 @@ import com.tangem.domain.pay.usecase.*
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -61,10 +63,18 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindCustomerOffersRepository(repository: DefaultCustomerOffersRepository): CustomerOffersRepository
@Binds
@Singleton
fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository
@Binds
@Singleton
fun bindCloseCardRepository(repository: DefaultCloseCardRepository): TangemPayCloseCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(factory: DefaultTangemPayCurrencyFactory): TangemPayCurrencyFactory
@ -75,6 +85,12 @@ internal interface TangemPayDataModule {
impl: DefaultGetTangemPayCurrencyStatusUseCase,
): GetTangemPayCurrencyStatusUseCase
@Binds
@Singleton
fun bindTangemPayWithdrawWithSwapUseCase(
impl: DefaultTangemPayWithdrawWithSwapUseCase,
): TangemPayWithdrawWithSwapUseCase
@Binds
@Singleton
fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase
@ -204,5 +220,57 @@ internal interface TangemPayDataModule {
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideCloseTangemPayCardUseCase(
closeCardRepository: TangemPayCloseCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CloseTangemPayCardUseCase {
return CloseTangemPayCardUseCase(
closeCardRepository = closeCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideGetCustomerOffersUseCase(
customerOffersRepository: CustomerOffersRepository,
): GetCustomerOffersUseCase {
return GetCustomerOffersUseCase(customerOffersRepository)
}
@Provides
fun provideCheckOrderConflictUseCase(
customerOrderRepository: CustomerOrderRepository,
): CheckOrderConflictUseCase {
return CheckOrderConflictUseCase(customerOrderRepository)
}
@Provides
fun provideRestoreActiveOrdersUseCase(
customerOrderRepository: CustomerOrderRepository,
): RestoreActiveOrdersUseCase {
return RestoreActiveOrdersUseCase(customerOrderRepository)
}
@Provides
fun provideValidateLocalOrderHintUseCase(
customerOrderRepository: CustomerOrderRepository,
onboardingRepository: OnboardingRepository,
): ValidateLocalOrderHintUseCase {
return ValidateLocalOrderHintUseCase(customerOrderRepository, onboardingRepository)
}
@Provides
fun provideIssueAdditionalCardUseCase(
customerOffersRepository: CustomerOffersRepository,
customerOrderRepository: CustomerOrderRepository,
): IssueAdditionalCardUseCase {
return IssueAdditionalCardUseCase(customerOffersRepository, customerOrderRepository)
}
}
}

View file

@ -7,9 +7,12 @@ 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.hasAccountData
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -19,16 +22,14 @@ 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.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.pay.repository.*
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.minutes
private const val TAG = "PaymentAccountStatusFetcher"
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val onboardingRepository: OnboardingRepository,
@ -50,6 +51,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val eligibilityManager: TangemPayEligibilityManager,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val singleQuoteSupplier: SingleQuoteStatusSupplier,
private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -149,8 +152,19 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private suspend fun proceedWithoutOrder(account: Account.Payment): PaymentAccountStatusValue {
return onboardingRepository.getCustomerInfo(account.userWalletId).fold(
ifLeft = { error ->
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
error.mapToPaymentAccountStatus(account.userWalletId)
val cache = paymentAccountStatusesStore.getSyncOrNull(account.userWalletId)
if (cache != null && cache.value.hasAccountData()) {
cache.value.copySealed(
source = StatusSource.ONLY_CACHE,
error = when (error) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
else -> PaymentAccountStatusValue.Error.Unavailable
},
)
} else {
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
error.mapToPaymentAccountStatus(account.userWalletId)
}
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
@ -265,8 +279,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
val quotesData = singleQuoteSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val cardInfo = this.cardInfo
val productInstance = this.productInstance
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
@ -281,19 +293,26 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate,
error = null,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
cards.isNotEmpty() && productInstances.isNotEmpty() &&
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
@ -301,50 +320,85 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
}
private suspend fun convertToContentState(
/**
* Builds the [PaymentAccountStatusValue.Loaded] content state with the full list of cards.
* Each card is the join of a product instance with its card info by `cardId`; balances are
* payment-account-level (shared across cards). Falls back to [PaymentAccountStatusValue.IssuingCard]
* when no card has both a product instance and card info yet (e.g. issuance in progress).
*/
private suspend fun CustomerInfo.convertToContentState(
userWalletId: UserWalletId,
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
fiatBalance: PaymentAccountStatusValue.FiatBalance,
cryptoBalance: PaymentAccountStatusValue.CryptoBalance,
customerId: String,
fiatRate: BigDecimal?,
): PaymentAccountStatusValue {
val reissueOrder = reissueCardRepository.getReissueOrderInfo(
userWalletId = userWalletId,
cardId = productInstance.cardId,
).getOrNull()
val cardsById = cards.associateBy { it.cardId }
val tangemPayCards = productInstances.mapNotNull { productInstance ->
val cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null
val cardId = productInstance.cardId
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)
TangemPayCard(
id = cardId,
productInstanceId = productInstance.id,
cardStatus = cardInfo.cardStatus,
hasPinCode = cardInfo.isPinSet,
displayName = productInstance.displayName,
limit = TangemPayCardLimitData(
actualCardLimit = productInstance.actualCardLimit,
adminCardLimit = productInstance.adminCardLimit,
),
frozenState = if (cardFrozenState == TangemPayCardFrozenState.Pending) {
TangemPayCardFrozenState.Pending
} else {
productInstance.frozenState
},
lastDigits = cardInfo.lastFourDigits,
state = getCardState(cardId, userWalletId),
)
}
val isReissuing = reissueOrder != null &&
reissueOrder.orderStatus != OrderStatus.CANCELED &&
reissueOrder.orderStatus != OrderStatus.COMPLETED
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
cryptoCurrency = cryptoCurrency,
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = listOf(
TangemPayCard(
id = productInstance.cardId,
hasPinCode = cardInfo.isPinSet,
displayName = productInstance.displayName,
limit = TangemPayCardLimitData(
actualCardLimit = productInstance.actualCardLimit,
adminCardLimit = productInstance.adminCardLimit,
),
isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen,
lastDigits = cardInfo.lastFourDigits,
isReissuing = isReissuing,
),
cards = tangemPayCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
error = null,
)
}
private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState {
val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull()
val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull()
return if (closingOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
closeCardRepository.setCloseOrderId(cardId, null)
TangemPayCardState.Active
} else {
TangemPayCardState.Closing
}
} else if (reissueOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
TangemPayCardState.Active
} else {
TangemPayCardState.Reissuing
}
} else {
TangemPayCardState.Active
}
}
private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced

View file

@ -0,0 +1,52 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CloseCardRequest
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultCloseCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayCloseCardStore: TangemPayCloseCardStore,
) : TangemPayCloseCardRepository {
override suspend fun closeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<VisaApiError, TangemPayOrderInfo> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.closeCard(
authHeader = authHeader,
body = CloseCardRequest(cardId = cardId),
)
}.bind()
TangemPayOrderInfo(
orderId = response.result.orderId,
orderStatus = OrderStatusConverter.convert(response.result.status),
)
}
override suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayCloseCardStore.setCloseOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> =
either {
runSuspendCatching { tangemPayCloseCardStore.getOrderId(cardId) }.getOrNull()
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.CustomerOffersResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Offer
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOffersRepository
import com.tangem.domain.visa.error.VisaApiError
import java.util.Currency
import javax.inject.Inject
internal class DefaultCustomerOffersRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : CustomerOffersRepository {
override suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCustomerOffers(authHeader = authHeader)
}.map { response ->
response.result.map { it.toDomain() }
}
}
private fun CustomerOffersResponse.Offer.toDomain(): Offer {
return Offer(
type = Offer.Type.fromString(type),
fee = Offer.Fee(amount = fee.amount, currency = Currency.getInstance(fee.currency)),
data = Offer.Data(
specificationName = data.specificationName,
orderType = OrderType.fromString(data.orderType),
),
)
}
}

View file

@ -1,11 +1,15 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.data.pay.util.OrderConverter
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
@ -27,4 +31,47 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
)
}
}
override suspend fun findOrders(
userWalletId: UserWalletId,
types: Set<OrderType>,
statuses: Set<OrderStatus>,
): Either<VisaApiError, List<Order>> {
val typeWire = types.map(OrderType::wireValue).takeIf { it.isNotEmpty() }
val statusWire = statuses.map(OrderStatus::name).takeIf { it.isNotEmpty() }
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.findOrders(
authHeader = authHeader,
orderTypes = typeWire,
orderStatuses = statusWire,
)
}.map { response ->
response.result.map(OrderConverter::convert)
}
}
override suspend fun createOrder(
userWalletId: UserWalletId,
type: OrderType,
specificationName: String,
idempotencyKey: String,
): Either<VisaApiError, Order> {
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.createOrder(
authHeader = authHeader,
body = OrderRequest(
data = OrderRequest.Data(
customerWalletAddress = walletAddress,
specificationName = specificationName,
type = type.wireValue,
),
idempotencyKey = idempotencyKey,
),
)
}.map { response ->
val result = requireNotNull(response.result) { "createOrder returned empty result" }
OrderConverter.convert(result)
}
}
}

View file

@ -30,6 +30,7 @@ import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -144,7 +145,10 @@ internal class DefaultOnboardingRepository @Inject constructor(
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
requestHelper.performRequest(userWalletId) { authHeader ->
val data = OrderRequest.Data(customerWalletAddress = walletAddress)
tangemPayApi.createOrder(authHeader, body = OrderRequest(data = data))
tangemPayApi.createOrder(
authHeader = authHeader,
body = OrderRequest(data = data, idempotencyKey = UUID.randomUUID().toString()),
)
}.map { response ->
val result = requireNotNull(response.result)
tangemPayStorage.storeOrderId(walletAddress, result.id)
@ -165,7 +169,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
val customerInfo = CustomerInfoConverter.convert(response)
sendKycAnalytics(customerInfo.kycStatus)
customerInfo.productInstance?.let { instance ->
// Keep the per-card frozen state up to date for every card.
customerInfo.productInstances.forEach { instance ->
cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
}

View file

@ -11,7 +11,6 @@ import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import com.tangem.domain.models.pay.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
@ -21,7 +20,6 @@ internal class DefaultReissueCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayReissueCardStore: TangemPayReissueCardStore,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : TangemPayReissueCardRepository {
override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either<VisaApiError, TangemPayReissueCardFee> =
@ -74,17 +72,11 @@ internal class DefaultReissueCardRepository @Inject constructor(
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getReissueOrderInfo(
override suspend fun getReissueOrderId(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayOrderInfo?> = either {
val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
if (orderId == null) {
return null.right()
}
cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind()
): Either<UniversalError, String?> = either {
runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
}
private companion object {

View file

@ -30,7 +30,7 @@ import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@ -75,7 +75,10 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
)
}
override suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails> {
override suspend fun revealCardDetails(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardDetails> {
return catch(
block = {
val publicKeyBase64 = getPublicKeyBase64()
@ -84,6 +87,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
requestHelper.performRequest(userWalletId = userWalletId) { authHeader ->
tangemPayApi.revealCardDetails(
authHeader = authHeader,
cardId = cardId,
body = CardDetailsRequest(sessionId = sessionId),
)
}.getOrNull()?.result,

View file

@ -2,6 +2,7 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.pay.TangemPayApi
@ -12,10 +13,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.pay.*
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
@ -60,7 +58,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
private val pollingJobs = mutableMapOf<PollingKey, Job>()
private val pollingMutex = Mutex()
override suspend fun withdraw(
override suspend fun withdrawWithSwap(
userWallet: UserWallet,
receiverAddress: String,
cryptoAmount: BigDecimal,
@ -100,7 +98,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
WithdrawalResult.Success
}
}
null -> return Either.Left(VisaApiError.SignWithdrawError)
null -> Either.Left(VisaApiError.SignWithdrawError)
}
}
}
@ -223,6 +221,46 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
}
}
override suspend fun withdraw(
userWallet: UserWallet,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, WithdrawalResult> {
val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId)
if (amountInCents.isNullOrEmpty()) return VisaApiError.WithdrawalDataError.left()
return requestHelper.performRequest(userWallet.walletId) { authHeader ->
val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress)
tangemPayApi.getWithdrawData(authHeader = authHeader, body = request)
}.map { data ->
val result = data.result ?: return VisaApiError.WithdrawalDataError.left()
val signatureResult = authDataSource.getWithdrawalSignature(
userWallet = userWallet,
hash = result.hash,
).getOrNull()
return when (signatureResult) {
is WithdrawalSignatureResult.Cancelled -> WithdrawalResult.Cancelled.right()
is WithdrawalSignatureResult.Success -> requestHelper.performRequest(
userWalletId = userWallet.walletId,
) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signatureResult.signature.addHexPrefix(),
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}.fold(
ifLeft = { VisaApiError.WithdrawError.left() },
ifRight = { WithdrawalResult.Success.right() },
)
null -> VisaApiError.SignWithdrawError.left()
}
}
}
override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean {
val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWalletId)
if (orderId.isNullOrEmpty()) return false

View file

@ -4,7 +4,6 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -20,14 +19,12 @@ internal class DefaultTangemPayWithdrawUseCase @Inject constructor(
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult> {
return repository.withdraw(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
receiverAddress = receiverCexAddress,
cryptoCurrencyId = cryptoCurrencyId,
exchangeData = exchangeData,
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.pay.usecase
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import java.math.BigDecimal
import javax.inject.Inject
internal class DefaultTangemPayWithdrawWithSwapUseCase @Inject constructor(
private val repository: TangemPayWithdrawRepository,
) : TangemPayWithdrawWithSwapUseCase {
override suspend fun invoke(
userWallet: UserWallet,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult> {
return repository.withdrawWithSwap(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
receiverAddress = receiverCexAddress,
cryptoCurrencyId = cryptoCurrencyId,
exchangeData = exchangeData,
)
}
}

View file

@ -7,64 +7,70 @@ import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, CustomerInfo> {
@Suppress("ComplexCondition")
override fun convert(value: CustomerMeResponse.Result): CustomerInfo {
val kycStatus = KycStatus.fromString(status = value.kyc?.status)
val card = value.card
val fiatBalance = value.balance?.fiat
val cryptoBalance = value.balance?.crypto
val paymentAccount = value.paymentAccount
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = fiatBalance.availableBalance,
currencyCode = fiatBalance.currency,
depositAddress = value.depositAddress,
isPinSet = value.card?.isPinSet == true,
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = cryptoBalance.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount.orZero(),
)
} else {
null
}
val productInstance = value.productInstance?.let { instance ->
val status = instance.status.toDomain()
val cardFrozenState = when (status) {
Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
val displayName = instance.displayName?.ifEmpty { null }
ProductInstance(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
status = status,
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
)
val productInstances = value.productInstances.map { it.toDomain() }
val cards = if (value.paymentAccount == null || value.balance == null) {
emptyList()
} else {
value.cards.mapIndexed { index, cardWire ->
// Legacy single-card has no card_id on the card object → join to the single product instance.
val cardId = cardWire.cardId ?: value.productInstances.getOrNull(index)?.cardId.orEmpty()
buildCardInfo(cardId = cardId, card = cardWire)
}
}
return CustomerInfo(
customerId = value.id,
productInstance = productInstance,
productInstances = productInstances,
cards = cards,
kycStatus = kycStatus,
cardInfo = cardInfo,
state = CustomerInfo.State.fromString(value.state),
fiatBalance = fiatBalance?.toDomain(),
cryptoBalance = cryptoBalance?.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount.orZero(),
)
}
private fun CustomerMeResponse.ProductInstance.toDomain(): ProductInstance {
val status = status.toDomain()
val cardFrozenState = when (status) {
Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
val name = displayName?.ifEmpty { null }
return ProductInstance(
id = id,
cardId = cardId,
frozenState = cardFrozenState,
status = status,
displayName = if (name != null) CardDisplayName(name).getOrElse { null } else null,
actualCardLimit = actualCardLimit?.parseCardLimit(),
adminCardLimit = adminCardLimit?.parseCardLimit(),
)
}
private fun buildCardInfo(cardId: String, card: CustomerMeResponse.Card): CardInfo {
return CardInfo(
cardId = cardId,
cardStatus = TangemPayCard.Status.fromString(card.cardStatus),
lastFourDigits = card.cardNumberEnd,
isPinSet = card.isPinSet == true,
)
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderType
/** Maps a wire `OrderResponse.Result` into the domain [Order] model. */
internal object OrderConverter {
fun convert(value: OrderResponse.Result): Order {
val status = OrderStatusConverter.convert(value.status)
val type = OrderType.fromString(value.type ?: value.data.type)
return Order(
id = value.id,
customerId = value.customerId,
type = type,
status = status,
step = value.step,
stepChangeCode = value.stepChangeCode,
productInstanceId = value.data.productInstanceId,
paymentAccountId = value.data.paymentAccountId,
cardId = null, // Card id not in v1 response shape; resolved via productInstanceId.
withdrawTxHash = value.data.transactionHash?.ifEmpty { null },
createdAt = value.createdAt,
updatedAt = value.updatedAt,
)
}
}

View file

@ -0,0 +1,113 @@
package com.tangem.data.virtualaccount.converter
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.VirtualAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Two-way converter between [VirtualAccountStatusValue] and [VirtualAccountStatusValueDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
@Singleton
internal class VirtualAccountStatusValueDMConverter @Inject constructor(
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
) {
fun convert(value: VirtualAccountStatusValue): VirtualAccountStatusValueDM? {
return when (value) {
is VirtualAccountStatusValue.Empty -> VirtualAccountStatusValueDM.Empty()
is VirtualAccountStatusValue.NotCreated -> VirtualAccountStatusValueDM.NotCreated()
is VirtualAccountStatusValue.UnderReview -> VirtualAccountStatusValueDM.UnderReview(
kycStatus = value.kycStatus,
customerId = value.customerId,
)
is VirtualAccountStatusValue.Provisioning -> VirtualAccountStatusValueDM.Provisioning()
is VirtualAccountStatusValue.CountryNotSupported -> VirtualAccountStatusValueDM.CountryNotSupported()
is VirtualAccountStatusValue.Active -> VirtualAccountStatusValueDM.ActiveAccount(
customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
fiatRate = value.fiatRate,
availableForWithdrawal = value.availableForWithdrawal,
)
// Transient statuses are not persisted
is VirtualAccountStatusValue.Loading,
is VirtualAccountStatusValue.Error.ExposedDevice,
is VirtualAccountStatusValue.Error.Unavailable,
is VirtualAccountStatusValue.Error.NotSynced,
-> null
}
}
fun convertBack(userWalletId: UserWalletId, value: VirtualAccountStatusValueDM?): VirtualAccountStatusValue {
return when (value) {
is VirtualAccountStatusValueDM.Empty -> VirtualAccountStatusValue.Empty
is VirtualAccountStatusValueDM.NotCreated -> VirtualAccountStatusValue.NotCreated
is VirtualAccountStatusValueDM.Provisioning -> VirtualAccountStatusValue.Provisioning(
source = StatusSource.CACHE,
)
is VirtualAccountStatusValueDM.CountryNotSupported -> VirtualAccountStatusValue.CountryNotSupported
is VirtualAccountStatusValueDM.UnderReview -> VirtualAccountStatusValue.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
customerId = value.customerId,
)
is VirtualAccountStatusValueDM.ActiveAccount -> VirtualAccountStatusValue.Active(
source = StatusSource.CACHE,
customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = value.fiatRate,
)
null -> VirtualAccountStatusValue.Error.Unavailable
}
}
private fun VirtualAccountStatusValue.FiatBalance.toDM(): VirtualAccountStatusValueDM.FiatBalanceDM {
return VirtualAccountStatusValueDM.FiatBalanceDM(
availableBalance = availableBalance,
currency = currency,
)
}
private fun VirtualAccountStatusValue.CryptoBalance.toDM(): VirtualAccountStatusValueDM.CryptoBalanceDM {
return VirtualAccountStatusValueDM.CryptoBalanceDM(
id = id,
chainId = chainId,
depositAddress = depositAddress,
tokenContractAddress = tokenContractAddress,
balance = balance,
)
}
private fun VirtualAccountStatusValueDM.FiatBalanceDM.toDomain(): VirtualAccountStatusValue.FiatBalance {
return VirtualAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
}
private fun VirtualAccountStatusValueDM.CryptoBalanceDM.toDomain(): VirtualAccountStatusValue.CryptoBalance {
return VirtualAccountStatusValue.CryptoBalance(
id = id,
chainId = chainId,
depositAddress = depositAddress,
tokenContractAddress = tokenContractAddress,
balance = balance,
)
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.data.virtualaccount.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface VirtualAccountDataModule {
@Binds
@Singleton
fun bindVirtualAccountStatusProducerFactory(
impl: DefaultVirtualAccountStatusProducer.Factory,
): VirtualAccountStatusProducer.Factory
@Binds
@Singleton
fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher
companion object {
@Provides
@Singleton
fun provideVirtualAccountStatusesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
scope: AppCoroutineScope,
converter: VirtualAccountStatusValueDMConverter,
): VirtualAccountStatusesStore {
return VirtualAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<VirtualAccountStatusValueDM>(),
defaultValue = emptyMap(),
),
corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() },
produceFile = { context.dataStoreFile(fileName = "virtual_account_statuses") },
scope = scope,
),
converter = converter,
scope = scope,
)
}
@Provides
@Singleton
fun provideVirtualAccountStatusSupplier(
factory: VirtualAccountStatusProducer.Factory,
): VirtualAccountStatusSupplier {
return object : VirtualAccountStatusSupplier(
factory = factory,
keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" },
) {}
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.data.virtualaccount.flow
import arrow.core.Either
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
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.VirtualAccountStatusValue
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
internal class DefaultVirtualAccountStatusFetcher @Inject constructor(
private val virtualAccountStatusesStore: VirtualAccountStatusesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : VirtualAccountStatusFetcher {
override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
val account = Account.Virtual(userWalletId = params.userWalletId)
// TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking
// details) from the backend once Virtual Account status endpoints are available. Until then the
// account is surfaced as NotCreated so the entity flows through the app end-to-end.
virtualAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated),
)
}.onLeft {
virtualAccountStatusesStore.updateStatusSource(
userWalletId = params.userWalletId,
source = StatusSource.ONLY_CACHE,
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.virtualaccount.flow
import arrow.core.Option
import arrow.core.some
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.VirtualAccountStatusValue
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
internal class DefaultVirtualAccountStatusProducer @AssistedInject constructor(
@Assisted private val params: VirtualAccountStatusProducer.Params,
override val flowProducerTools: FlowProducerTools,
private val virtualAccountStatusesStore: VirtualAccountStatusesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : VirtualAccountStatusProducer {
private val logger = TangemLogger.withTag(TAG)
private val account = Account.Virtual(userWalletId = params.userWalletId)
override val fallback: Option<AccountStatus.Virtual>
get() = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.Error.Unavailable).some()
override fun produce(): Flow<AccountStatus.Virtual> {
return virtualAccountStatusesStore.get(userWalletId = params.userWalletId)
.map { status ->
if (status != null) {
logger.i("[${params.userWalletId}] flow emits statusType=${status.value::class.simpleName}")
AccountStatus.Virtual(
account = account,
value = status.value,
)
} else {
logger.i("[${params.userWalletId}] status is null: emitting Empty fallback")
AccountStatus.Virtual(
account = account,
value = VirtualAccountStatusValue.Empty,
)
}
}
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : VirtualAccountStatusProducer.Factory {
override fun create(params: VirtualAccountStatusProducer.Params): DefaultVirtualAccountStatusProducer
}
private companion object {
private const val TAG = "VirtualAccountStatusProducer"
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.data.virtualaccount.store
import androidx.datastore.core.DataStore
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
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.VirtualAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal typealias WalletIdWithVirtualStatus = Map<String, AccountStatus.Virtual>
internal typealias WalletIdWithVirtualStatusDM = Map<String, VirtualAccountStatusValueDM>
/**
* Store for virtual account statuses with dual storage (runtime + persistence).
*
* @property runtimeStore runtime store for fast in-memory access
* @property persistenceDataStore persistence store for caching across app restarts
*/
internal class VirtualAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithVirtualStatus>,
private val persistenceDataStore: DataStore<WalletIdWithVirtualStatusDM>,
private val converter: VirtualAccountStatusValueDMConverter,
scope: AppCoroutineScope,
) {
private val logger = TangemLogger.withTag(TAG)
init {
scope.launch {
try {
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) ->
val account = Account.Virtual(userWalletId = UserWalletId(rawUserWalletId))
val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM)
AccountStatus.Virtual(account = account, value = statusValue)
},
)
} catch (e: Exception) {
runSuspendCatching { persistenceDataStore.updateData { emptyMap() } }
logger.e("Error while loading cached virtual account statuses", e)
}
}
}
fun get(userWalletId: UserWalletId): Flow<AccountStatus.Virtual?> {
return runtimeStore.get()
.onStart { logger.i("get($userWalletId): subscribed to runtimeStore") }
.onEach { map ->
logger.i(
"get($userWalletId): runtimeStore emitted map size=${map.size}, " +
"hasEntry=${map.containsKey(userWalletId.stringValue)}",
)
}
.map { it[userWalletId.stringValue] }
}
suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Virtual? {
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
}
suspend fun updateStatusSource(userWalletId: UserWalletId, source: StatusSource) {
runtimeStore.update(emptyMap()) { stored ->
stored.toMutableMap().apply {
val status = this[userWalletId.stringValue] ?: return@update stored
val newValue = status.copy(value = status.value.copySealed(source = source))
put(key = userWalletId.stringValue, value = newValue)
}
}
}
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Virtual) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status.value) }
}
}
suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Virtual) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
put(key = userWalletId.stringValue, value = status)
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: VirtualAccountStatusValue) {
val statusDM = converter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)
}
}
}
private companion object {
private const val TAG = "VirtualAccountStatusesStore"
}
}

View file

@ -7,13 +7,13 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@ -35,6 +35,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor(
override suspend fun revealCardDetails(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardDetails> {
if (isMockMode) {
return TangemPayCardDetails(
@ -44,7 +45,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor(
expirationMonth = MOCK_EXPIRATION_MONTH,
).right()
}
return real.revealCardDetails(userWalletId)
return real.revealCardDetails(userWalletId, cardId)
}
override suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> {

View file

@ -64,13 +64,18 @@ internal class PaymentAccountStatusValueDMConverterTest {
// GIVEN
val domain = PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
customerId = "customer-1",
balance = PaymentAccountStatusValue.Balance(
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
),
cryptoBalance = cryptoBalance(),
availableForWithdrawal = BigDecimal("7"),
),
cryptoBalance = cryptoBalance(),
cryptoCurrency = cryptoCurrency,
fiatRate = BigDecimal("1.05"),
error = null,
)
// WHEN
@ -79,8 +84,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.customerId).isEqualTo("customer-1")
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
assertThat(dm.availableForWithdrawal).isEqualTo(BigDecimal("7"))
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
}
@ -141,12 +148,14 @@ internal class PaymentAccountStatusValueDMConverterTest {
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = "customer-2",
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("200"),
currency = "EUR",
),
cryptoBalance = cryptoBalanceDM(),
fiatRate = BigDecimal("0.92"),
availableForWithdrawal = BigDecimal("5"),
)
// WHEN
@ -156,8 +165,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
val deactivated = result as PaymentAccountStatusValue.Deactivated
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
assertThat(deactivated.customerId).isEqualTo("customer-2")
assertThat(deactivated.balance.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.balance.fiatBalance.currency).isEqualTo("EUR")
assertThat(deactivated.balance.availableForWithdrawal).isEqualTo(BigDecimal("5"))
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
}

View file

@ -0,0 +1,389 @@
package com.tangem.data.pay.repository
import arrow.core.left
import arrow.core.right
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.WithdrawDataResponse
import com.tangem.datasource.api.pay.models.response.WithdrawResponse
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.feature.swap.domain.api.SwapRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultTangemPayWithdrawRepositoryTest {
private val tangemPayApi: TangemPayApi = mockk()
private val requestHelper: TangemPayRequestPerformer = mockk()
private val authDataSource: TangemPayAuthDataSource = mockk()
private val quotesFetcher: QuotesFetcher = mockk()
private val tangemPayStorage: TangemPayStorage = mockk(relaxUnitFun = true)
private val swapRepository: SwapRepository = mockk()
private val orderRepository: CustomerOrderRepository = mockk()
private val userWalletId = UserWalletId("011")
private val userWallet: UserWallet = mockk {
every { walletId } returns userWalletId
}
private val cryptoCurrencyId = CryptoCurrency.RawID(CURRENCY_ID)
private val exchangeData = TangemPayWithdrawExchangeState(
txId = "txId",
fromNetwork = "ETH",
fromAddress = "0xFrom",
payInAddress = "0xPayIn",
payInExtraId = null,
)
private val orderWithoutHash = OrderData(
customerId = "customer",
status = OrderStatus.PROCESSING,
withdrawTxHash = null,
)
private val orderWithHash = orderWithoutHash.copy(withdrawTxHash = TX_HASH)
@BeforeEach
fun setUp() {
// Valid fiat rate so amountInCents resolves to a non-empty value.
coEvery {
quotesFetcher.fetch(fiatCurrencyId = any(), currencyId = any(), field = any())
} returns QuotesResponse(
quotes = mapOf(CURRENCY_ID to QuotesResponse.Quote.EMPTY.copy(price = BigDecimal.ONE)),
).right()
// performRequest is treated as a transparent pass-through: it invokes the request block and
// maps the ApiResponse to Either, so each test can drive behaviour via the TangemPayApi mock.
coEvery {
requestHelper.performRequest<Any>(userWalletId = any(), requestBlock = any())
} coAnswers {
val block = secondArg<suspend (String) -> ApiResponse<Any>>()
when (val response = block(AUTH_HEADER)) {
is ApiResponse.Success -> response.data.right()
is ApiResponse.Error -> VisaApiError.WithdrawError.left()
}
}
coEvery { tangemPayApi.getWithdrawData(any(), any()) } returns ApiResponse.Success(
WithdrawDataResponse(
result = WithdrawDataResponse.Result(hash = "hash", salt = "salt", senderAddress = "sender"),
),
)
coEvery { tangemPayApi.withdraw(any(), any()) } returns ApiResponse.Success(
WithdrawResponse(
result = WithdrawResponse.Result(orderId = ORDER_ID, status = "NEW", type = "withdraw"),
),
)
coEvery {
authDataSource.getWithdrawalSignature(any(), any())
} returns WithdrawalSignatureResult.Success(SIGNATURE).right()
coEvery { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) } returns Unit.right()
}
// region withdrawWithSwap
@Test
fun `GIVEN amountInCents is null WHEN withdrawWithSwap THEN return WithdrawalDataError`() = runTest {
coEvery {
quotesFetcher.fetch(fiatCurrencyId = any(), currencyId = any(), field = any())
} returns QuotesFetcher.Error.CacheOperationError.left()
val result = createRepository().withdrawWithSwap()
Assertions.assertEquals(VisaApiError.WithdrawalDataError.left(), result)
coVerify(exactly = 0) { tangemPayApi.getWithdrawData(any(), any()) }
}
@Test
fun `GIVEN getWithdrawData result is null WHEN withdrawWithSwap THEN return WithdrawalDataError`() = runTest {
coEvery { tangemPayApi.getWithdrawData(any(), any()) } returns ApiResponse.Success(
WithdrawDataResponse(result = null),
)
val result = createRepository().withdrawWithSwap()
Assertions.assertEquals(VisaApiError.WithdrawalDataError.left(), result)
coVerify(exactly = 0) { authDataSource.getWithdrawalSignature(any(), any()) }
}
@Test
fun `GIVEN withdrawal signature is null WHEN withdrawWithSwap THEN return SignWithdrawError`() = runTest {
coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns RuntimeException("error").left()
val result = createRepository().withdrawWithSwap()
Assertions.assertEquals(VisaApiError.SignWithdrawError.left(), result)
coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) }
}
@Test
fun `GIVEN withdrawal signature is Cancelled WHEN withdrawWithSwap THEN return Cancelled`() = runTest {
coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns WithdrawalSignatureResult.Cancelled.right()
val result = createRepository().withdrawWithSwap()
Assertions.assertEquals(WithdrawalResult.Cancelled.right(), result)
coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) }
}
@Test
fun `GIVEN withdraw returns error WHEN withdrawWithSwap THEN return WithdrawError`() = runTest {
coEvery { tangemPayApi.withdraw(any(), any()) } returns
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<WithdrawResponse>
val result = createRepository().withdrawWithSwap()
Assertions.assertEquals(VisaApiError.WithdrawError.left(), result)
coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) }
}
@Test
fun `GIVEN no txHash on every attempt WHEN withdrawWithSwap THEN polling deletes order after max attempts`() =
runTest {
coEvery { orderRepository.getOrderData(any(), any()) } returns orderWithoutHash.right()
val result = createRepository().withdrawWithSwap()
advanceUntilIdle()
Assertions.assertEquals(WithdrawalResult.Success.right(), result)
// 1 initial check + MAX_POLLING_ATTEMPTS (6) polling attempts.
coVerify(exactly = 7) { orderRepository.getOrderData(userWalletId, ORDER_ID) }
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
coVerify(exactly = 0) { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `GIVEN getOrderData throws while polling WHEN withdrawWithSwap THEN polling deletes order`() = runTest {
coEvery {
orderRepository.getOrderData(any(), any())
} returns orderWithoutHash.right() andThenThrows RuntimeException("boom")
val result = createRepository().withdrawWithSwap()
advanceUntilIdle()
Assertions.assertEquals(WithdrawalResult.Success.right(), result)
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
coVerify(exactly = 0) { swapRepository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `GIVEN txHash appears on the last attempt WHEN withdrawWithSwap THEN polling finalizes the withdrawal`() =
runTest {
// index 0 = initial check, 1..5 = polling attempts 1-5, 6 = polling attempt 6 (last) returns the hash.
coEvery { orderRepository.getOrderData(any(), any()) } returnsMany
List(size = 6) { orderWithoutHash.right() } + listOf(orderWithHash.right())
val result = createRepository().withdrawWithSwap()
advanceUntilIdle()
Assertions.assertEquals(WithdrawalResult.Success.right(), result)
assertExchangeSent()
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
}
// endregion
// region withdraw
@Test
fun `GIVEN withdrawal signature is Cancelled WHEN withdraw THEN return Cancelled`() = runTest {
coEvery { authDataSource.getWithdrawalSignature(any(), any()) } returns WithdrawalSignatureResult.Cancelled.right()
val result = createRepository().withdraw()
Assertions.assertEquals(WithdrawalResult.Cancelled.right(), result)
coVerify(exactly = 0) { tangemPayApi.withdraw(any(), any()) }
}
@Test
fun `GIVEN withdraw succeeds WHEN withdraw THEN return Success`() = runTest {
val result = createRepository().withdraw()
Assertions.assertEquals(WithdrawalResult.Success.right(), result)
coVerify { tangemPayApi.withdraw(any(), any()) }
}
// endregion
// region hasWithdrawOrder
@Test
fun `GIVEN no active order id WHEN hasWithdrawOrder THEN return false`() = runTest {
coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns null
val result = createRepository().hasWithdrawOrder(userWalletId)
Assertions.assertFalse(result)
coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) }
}
@Test
fun `GIVEN order is not active WHEN hasWithdrawOrder THEN delete active order and return false`() = runTest {
coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns ORDER_ID
coEvery {
orderRepository.getOrderData(userWalletId, ORDER_ID)
} returns orderWithoutHash.copy(status = OrderStatus.COMPLETED).right()
val result = createRepository().hasWithdrawOrder(userWalletId)
Assertions.assertFalse(result)
coVerify { tangemPayStorage.deleteActiveWithdrawOrder(userWalletId) }
}
@Test
fun `GIVEN order is active WHEN hasWithdrawOrder THEN return true and keep active order`() = runTest {
coEvery { tangemPayStorage.getActiveWithdrawOrderId(userWalletId) } returns ORDER_ID
coEvery {
orderRepository.getOrderData(userWalletId, ORDER_ID)
} returns orderWithoutHash.copy(status = OrderStatus.NEW).right()
val result = createRepository().hasWithdrawOrder(userWalletId)
Assertions.assertTrue(result)
coVerify(exactly = 0) { tangemPayStorage.deleteActiveWithdrawOrder(userWalletId) }
}
// endregion
// region pollWithdrawOrdersIfNeeds
@Test
fun `GIVEN stored hash is null and order hash appears on third attempt WHEN poll THEN finalize the withdrawal`() =
runTest {
coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = null))
// index 0 = initial fetch, 1..2 = polling attempts 1-2, 3 = polling attempt 3 returns the hash.
coEvery { orderRepository.getOrderData(any(), any()) } returnsMany
List(size = 3) { orderWithoutHash.right() } + listOf(orderWithHash.right())
createRepository().pollWithdrawOrdersIfNeeds(userWallet)
advanceUntilIdle()
coVerify(exactly = 4) { orderRepository.getOrderData(userWalletId, ORDER_ID) }
assertExchangeSent()
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
}
@Test
fun `GIVEN stored hash is null and order already has hash WHEN poll THEN finalize without polling`() = runTest {
coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = null))
coEvery { orderRepository.getOrderData(userWalletId, ORDER_ID) } returns orderWithHash.right()
createRepository().pollWithdrawOrdersIfNeeds(userWallet)
advanceUntilIdle()
coVerify(exactly = 1) { orderRepository.getOrderData(userWalletId, ORDER_ID) }
assertExchangeSent()
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
}
@Test
fun `GIVEN stored hash has value WHEN poll THEN finalize without fetching the order`() = runTest {
coEvery { tangemPayStorage.getWithdrawOrders(userWalletId) } returns listOf(storedOrder(txHash = TX_HASH))
createRepository().pollWithdrawOrdersIfNeeds(userWallet)
advanceUntilIdle()
coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) }
assertExchangeSent()
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
}
@Test
fun `GIVEN two identical orders WHEN poll THEN only a single polling job runs for the same order`() = runTest {
val duplicatedOrder = storedOrder(txHash = null)
coEvery {
tangemPayStorage.getWithdrawOrders(userWalletId)
} returns listOf(duplicatedOrder, duplicatedOrder)
coEvery { orderRepository.getOrderData(any(), any()) } returns orderWithoutHash.right()
createRepository().pollWithdrawOrdersIfNeeds(userWallet)
advanceUntilIdle()
// 2 initial fetches (one per order) + a single deduplicated polling job of 6 attempts = 8.
coVerify(exactly = 8) { orderRepository.getOrderData(userWalletId, ORDER_ID) }
coVerify { tangemPayStorage.deleteWithdrawOrder(userWalletId, ORDER_ID) }
}
// endregion
private fun assertExchangeSent() {
coVerify {
swapRepository.exchangeSent(
userWallet = userWallet,
txId = exchangeData.txId,
fromNetwork = exchangeData.fromNetwork,
fromAddress = exchangeData.fromAddress,
payInAddress = exchangeData.payInAddress,
txHash = TX_HASH,
payInExtraId = exchangeData.payInExtraId,
)
}
}
private fun storedOrder(txHash: String?) = TangemPayWithdrawState(
orderId = ORDER_ID,
exchangeData = exchangeData,
txHash = txHash,
)
private suspend fun DefaultTangemPayWithdrawRepository.withdrawWithSwap() = withdrawWithSwap(
userWallet = userWallet,
receiverAddress = RECEIVER_ADDRESS,
cryptoAmount = BigDecimal("1.5"),
cryptoCurrencyId = cryptoCurrencyId,
exchangeData = exchangeData,
)
private suspend fun DefaultTangemPayWithdrawRepository.withdraw() = withdraw(
userWallet = userWallet,
receiverAddress = RECEIVER_ADDRESS,
cryptoAmount = BigDecimal("1.5"),
cryptoCurrencyId = cryptoCurrencyId,
)
private fun TestScope.createRepository() = DefaultTangemPayWithdrawRepository(
tangemPayApi = tangemPayApi,
requestHelper = requestHelper,
authDataSource = authDataSource,
quotesFetcher = quotesFetcher,
tangemPayStorage = tangemPayStorage,
swapRepository = swapRepository,
orderRepository = orderRepository,
withdrawPollingScope = TestAppCoroutineScope(this),
)
private companion object {
const val CURRENCY_ID = "ethereum"
const val ORDER_ID = "order-1"
const val TX_HASH = "0xTxHash"
const val SIGNATURE = "0xSignature"
const val AUTH_HEADER = "auth-header"
const val RECEIVER_ADDRESS = "0xReceiver"
}
}