Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 15:59:34 +05:00
parent 640ba5af98
commit 6f257991e5
39 changed files with 1526 additions and 110 deletions

View file

@ -5,12 +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.TangemPayCardFrozenState
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.TangemPayCardState
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
@ -48,6 +43,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
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,
@ -103,6 +100,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
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(

View file

@ -63,6 +63,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindCustomerOffersRepository(repository: DefaultCustomerOffersRepository): CustomerOffersRepository
@Binds
@Singleton
fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository
@ -231,5 +235,42 @@ internal interface TangemPayDataModule {
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

@ -10,7 +10,9 @@ 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
@ -20,17 +22,10 @@ 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.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.pay.model.isFinalStatus
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -284,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
@ -315,10 +308,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
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" },
)
@ -326,45 +320,57 @@ 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 cardId = productInstance.cardId
val cardState = getCardState(cardId, userWalletId)
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
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),
)
}
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
depositAddress = cardInfo.depositAddress,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = listOf(
TangemPayCard(
id = cardId,
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 = cardState,
),
cards = tangemPayCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
error = null,
)
@ -375,7 +381,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull()
return if (closingOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull()
if (order != null && order.orderStatus.isFinalStatus) {
if (order != null && order.orderStatus.isTerminal) {
closeCardRepository.setCloseOrderId(cardId, null)
TangemPayCardState.Active
} else {
@ -383,7 +389,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
} else if (reissueOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull()
if (order != null && order.orderStatus.isFinalStatus) {
if (order != null && order.orderStatus.isTerminal) {
TangemPayCardState.Active
} else {
TangemPayCardState.Reissuing

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

@ -7,65 +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.models.pay.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,
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,
)
}
}