Updated on 2026-08-14
This commit is contained in:
parent
640ba5af98
commit
6f257991e5
39 changed files with 1526 additions and 110 deletions
|
|
@ -267,6 +267,19 @@ internal class DefaultTangemPayStorage @Inject constructor(
|
|||
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
|
||||
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
|
||||
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
|
||||
// Clear the withdraw order hints together with the rest of the cache.
|
||||
deleteActiveWithdrawOrder(userWalletId)
|
||||
clearWithdrawOrders(userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun clearWithdrawOrders(userWalletId: UserWalletId) {
|
||||
appPreferencesStore.editData { prefs ->
|
||||
val walletKey = createWithdrawOrderIdKey(userWalletId)
|
||||
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
|
||||
.orEmpty()
|
||||
val updatedMap = currentMap - walletKey
|
||||
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address"
|
||||
|
|
|
|||
|
|
@ -46,12 +46,28 @@ interface TangemPayApi {
|
|||
@Path("order_id") orderId: String,
|
||||
): ApiResponse<OrderResponse>
|
||||
|
||||
/**
|
||||
* Find user orders, filtered by types and/or statuses. Source of truth for resolving active orders.
|
||||
*
|
||||
* Multiple values for the same query key are sent as repeated `order_types=A&order_types=B` params.
|
||||
*/
|
||||
@GET("v1/order")
|
||||
suspend fun findOrders(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Query("order_types") orderTypes: List<String>?,
|
||||
@Query("order_statuses") orderStatuses: List<String>?,
|
||||
): ApiResponse<FindOrdersResponse>
|
||||
|
||||
@POST("v1/order")
|
||||
suspend fun createOrder(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Body body: OrderRequest,
|
||||
): ApiResponse<OrderResponse>
|
||||
|
||||
/** Customer offers — used to gate the issue-additional-card flow. */
|
||||
@GET("v1/customer/offers")
|
||||
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>
|
||||
|
||||
@GET("v1/customer/balance")
|
||||
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.squareup.moshi.Json
|
|||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OrderRequest(@Json(name = "data") val data: Data) {
|
||||
data class OrderRequest(
|
||||
@Json(name = "data") val data: Data,
|
||||
@Json(name = "idempotency_key") val idempotencyKey: String,
|
||||
) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Data(
|
||||
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ data class CustomerMeResponse(
|
|||
@Json(name = "deposit_address") val depositAddress: String?,
|
||||
@Json(name = "card") val card: Card?,
|
||||
@Json(name = "balance") val balance: BalanceResponse?,
|
||||
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
|
||||
@Json(name = "cards") val cards: List<Card>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
|
@ -99,6 +101,9 @@ data class CustomerMeResponse(
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Card(
|
||||
// Present in the multi-card `cards[]` array to join a card to its product instance;
|
||||
// absent in the legacy single-card `card` object, where the card joins the single product instance.
|
||||
@Json(name = "card_id") val cardId: String?,
|
||||
@Json(name = "token") val token: String,
|
||||
@Json(name = "expiration_month") val expirationMonth: String,
|
||||
@Json(name = "expiration_year") val expirationYear: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.datasource.api.pay.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Response from `GET /v1/customer/offers` — list of offers available to the customer.
|
||||
*
|
||||
* Used to gate the issue-additional-card flow.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CustomerOffersResponse(
|
||||
@Json(name = "result") val result: List<Offer>,
|
||||
) {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Offer(
|
||||
@Json(name = "type") val type: String,
|
||||
@Json(name = "fee") val fee: Fee,
|
||||
@Json(name = "data") val data: Data,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Data(
|
||||
@Json(name = "specification_name") val specificationName: String,
|
||||
@Json(name = "order_type") val orderType: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Fee(
|
||||
@Json(name = "amount") val amount: BigDecimal,
|
||||
@Json(name = "currency") val currency: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.datasource.api.pay.models.response
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Response from `GET /v1/order` (findOrders) — array of orders matching the requested
|
||||
* `order_types` / `order_statuses` filters.
|
||||
*
|
||||
* Each order shares the same shape as the single-order [OrderResponse.Result].
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FindOrdersResponse(
|
||||
@Json(name = "result") val result: List<OrderResponse.Result>,
|
||||
)
|
||||
|
|
@ -85,6 +85,8 @@ sealed interface PaymentAccountStatusValueDM {
|
|||
@JsonClass(generateAdapter = true)
|
||||
data class TangemPayCard(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "product_instance_id") val productInstanceId: String,
|
||||
@Json(name = "card_status") val cardStatus: String,
|
||||
@Json(name = "has_pin_code") val hasPinCode: Boolean,
|
||||
@Json(name = "display_name") val displayName: String?,
|
||||
@Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,23 +8,61 @@ import kotlinx.serialization.Serializable
|
|||
* Represents a Tangem Pay card linked to a payment account.
|
||||
*
|
||||
* @property id unique card identifier assigned by the backend.
|
||||
* @property productInstanceId identifier of the owning product instance; used to join a card to its
|
||||
* product instance and to scope card orders.
|
||||
* @property cardStatus backend card status; unknown values map to [Status.UNDEFINED].
|
||||
* @property hasPinCode whether the card has a PIN code set.
|
||||
* @property displayName optional human-readable name assigned to the card; `null` if not set.
|
||||
* @property limit spending limit configuration for the card; `null` if not configured or not yet loaded.
|
||||
* @property frozenState whether the card is currently frozen (blocked for payments).
|
||||
* @property lastDigits The last four digits of the card number.
|
||||
* @property state current lifecycle state of the card.
|
||||
* @property state current lifecycle state of the card (reissuing / closing / active).
|
||||
*/
|
||||
@Serializable
|
||||
data class TangemPayCard(
|
||||
@SerialName("id") val id: String,
|
||||
@SerialName("product_instance_id") val productInstanceId: String,
|
||||
@SerialName("card_status") val cardStatus: Status,
|
||||
@SerialName("has_pin_code") val hasPinCode: Boolean,
|
||||
@SerialName("display_name") val displayName: CardDisplayName?,
|
||||
@SerialName("limit") val limit: TangemPayCardLimitData?,
|
||||
@SerialName("frozen_state") val frozenState: TangemPayCardFrozenState,
|
||||
@SerialName("last_digits") val lastDigits: String,
|
||||
@SerialName("state") val state: TangemPayCardState,
|
||||
)
|
||||
) {
|
||||
|
||||
/** Backend card status — unknown values map to [UNDEFINED] without crashing. */
|
||||
@Serializable
|
||||
enum class Status {
|
||||
@SerialName("ACTIVE")
|
||||
ACTIVE,
|
||||
|
||||
@SerialName("INACTIVE")
|
||||
INACTIVE,
|
||||
|
||||
@SerialName("BLOCKED")
|
||||
BLOCKED,
|
||||
|
||||
@SerialName("CANCELED")
|
||||
CANCELED,
|
||||
|
||||
@SerialName("UNDEFINED")
|
||||
UNDEFINED,
|
||||
;
|
||||
|
||||
val isActive: Boolean get() = this == ACTIVE
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): Status = when (value?.uppercase()) {
|
||||
"ACTIVE" -> ACTIVE
|
||||
"INACTIVE" -> INACTIVE
|
||||
"BLOCKED" -> BLOCKED
|
||||
"CANCELED" -> CANCELED
|
||||
else -> UNDEFINED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val TangemPayCard.isFrozen
|
||||
get() = frozenState == TangemPayCardFrozenState.Frozen
|
||||
|
|
@ -55,6 +55,7 @@ sealed class VisaApiError(
|
|||
data object ProductInstanceIsNotActivated : VisaApiError(104110208)
|
||||
data object ProductInstanceIsAlreadyActivated : VisaApiError(104110207)
|
||||
data object CustomerIsBlocked : VisaApiError(104110210)
|
||||
data object CardIssueInsufficientBalance : VisaApiError(104140116)
|
||||
data object UnknownWithoutCode : VisaApiError(104110999)
|
||||
data class Unknown(override val errorCode: Int) : VisaApiError(errorCode)
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ sealed class VisaApiError(
|
|||
ProductInstanceIsNotActivated.errorCode -> ProductInstanceIsNotActivated
|
||||
ProductInstanceIsAlreadyActivated.errorCode -> ProductInstanceIsAlreadyActivated
|
||||
CustomerIsBlocked.errorCode -> CustomerIsBlocked
|
||||
CardIssueInsufficientBalance.errorCode -> CardIssueInsufficientBalance
|
||||
else -> Unknown(universalErrorCode)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ package com.tangem.domain.pay.model
|
|||
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.TangemPayCardLimit
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -22,14 +23,21 @@ data class MainScreenCustomerInfo(
|
|||
|
||||
data class CustomerInfo(
|
||||
val customerId: String?,
|
||||
val productInstance: ProductInstance?,
|
||||
val productInstances: List<ProductInstance>,
|
||||
val cards: List<CardInfo>,
|
||||
val kycStatus: KycStatus,
|
||||
val cardInfo: CardInfo?,
|
||||
val state: State,
|
||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
|
||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
|
||||
val availableForWithdrawal: BigDecimal?,
|
||||
val availableForWithdrawal: BigDecimal,
|
||||
) {
|
||||
|
||||
/** Transitional single-card accessor — returns the first product instance, or null if none. */
|
||||
val productInstance: ProductInstance? get() = productInstances.firstOrNull()
|
||||
|
||||
/** Transitional single-card accessor — returns the first card, or null if none. */
|
||||
val cardInfo: CardInfo? get() = cards.firstOrNull()
|
||||
|
||||
enum class State {
|
||||
NEW,
|
||||
ACTIVE,
|
||||
|
|
@ -77,13 +85,10 @@ data class CustomerInfo(
|
|||
}
|
||||
|
||||
data class CardInfo(
|
||||
/** Card identifier — matches [ProductInstance.cardId] to join a card to its product instance. */
|
||||
val cardId: String,
|
||||
val cardStatus: TangemPayCard.Status,
|
||||
val lastFourDigits: String,
|
||||
val balance: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val depositAddress: String?,
|
||||
val isPinSet: Boolean,
|
||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance,
|
||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance,
|
||||
val availableForWithdrawal: BigDecimal,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Customer offer returned by `GET /v1/customer/offers`.
|
||||
*
|
||||
* Used to gate the issue-additional-card flow: the offer fee drives the popup amount, and the
|
||||
* presence of the offer enables the "+" action.
|
||||
*/
|
||||
data class Offer(
|
||||
val type: Type,
|
||||
val fee: Fee,
|
||||
val data: Data,
|
||||
) {
|
||||
|
||||
data class Data(val specificationName: String, val orderType: OrderType)
|
||||
|
||||
/** Offer type — unknown wire values resolve to [UNKNOWN]. */
|
||||
enum class Type(val wireValue: String) {
|
||||
CARD_ISSUE_VIRTUAL_RAIN("CARD_ISSUE_VIRTUAL_RAIN"),
|
||||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): Type {
|
||||
if (value.isNullOrBlank()) return UNKNOWN
|
||||
return entries.firstOrNull { it.wireValue == value || it.name == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Fee(
|
||||
val amount: BigDecimal,
|
||||
val currency: Currency,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
/**
|
||||
* Domain model for a TangemPay order returned by `GET /v1/order` (findOrders) or `GET /v1/order/{id}`.
|
||||
*
|
||||
* Each order carries enough context to be matched to the originating card / product instance
|
||||
* for card-scoped flows.
|
||||
*
|
||||
* @property id backend order identifier.
|
||||
* @property type order type; unknown values resolve to [OrderType.UNKNOWN].
|
||||
* @property status current order status.
|
||||
* @property step optional per-status step indicator (KYC / Rain / Issue / Fee / Activation / …).
|
||||
* @property stepChangeCode optional code accompanying step transitions.
|
||||
* @property productInstanceId set for card-scoped orders.
|
||||
* @property paymentAccountId set for card-scoped and payment-account-level orders.
|
||||
* @property cardId set for card-scoped orders that are filtered by card.
|
||||
* @property withdrawTxHash present for completed [OrderType.WITHDRAW] orders.
|
||||
* @property updatedAt ISO-8601 timestamp used to pick the most recent matching order.
|
||||
*/
|
||||
data class Order(
|
||||
val id: String,
|
||||
val customerId: String?,
|
||||
val type: OrderType,
|
||||
val status: OrderStatus,
|
||||
val step: String?,
|
||||
val stepChangeCode: Int?,
|
||||
val productInstanceId: String?,
|
||||
val paymentAccountId: String?,
|
||||
val cardId: String?,
|
||||
val withdrawTxHash: String?,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?,
|
||||
) {
|
||||
/** True when the order belongs to a specific card/product instance (vs payment-account-level). */
|
||||
val isCardScoped: Boolean get() = productInstanceId != null
|
||||
|
||||
/** True when the order is still in flight. */
|
||||
val isActive: Boolean get() = status.isActive
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
/**
|
||||
* Decides whether a requested user action is allowed given the set of currently active orders
|
||||
* (an order is active while its status is NEW or PROCESSING).
|
||||
*
|
||||
* Rules:
|
||||
* - Issue (any card) — blocks Issue; does not block withdraw / freeze-unfreeze / rename of others.
|
||||
* - Freeze A — blocks Freeze A and Unfreeze A.
|
||||
* - Unfreeze A — symmetric to Freeze A.
|
||||
* - Withdraw — blocks Withdraw; does not block freeze-unfreeze / rename.
|
||||
* - Reissue A — blocks Freeze A / Unfreeze A / Reissue A.
|
||||
* - Rename — never blocked.
|
||||
*/
|
||||
sealed interface ConflictResolution {
|
||||
data object Allowed : ConflictResolution
|
||||
|
||||
/**
|
||||
* @property blockingOrder the active order that blocks the requested intent — useful for
|
||||
* routing the user to the in-flight progress screen instead of a flat error.
|
||||
*/
|
||||
data class Blocked(val blockingOrder: Order) : ConflictResolution
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct user-driven intents that may conflict with active orders.
|
||||
*
|
||||
* Card-scoped intents carry `productInstanceId`: orders are matched by product instance because the
|
||||
* v1 order response carries `productInstanceId` but no card id (see [Order.cardId]). The caller has
|
||||
* the product instance via `TangemPayCard.productInstanceId`.
|
||||
*/
|
||||
sealed interface OrderIntent {
|
||||
data object IssueCard : OrderIntent
|
||||
data class Freeze(val productInstanceId: String) : OrderIntent
|
||||
data class Unfreeze(val productInstanceId: String) : OrderIntent
|
||||
data class Reissue(val productInstanceId: String) : OrderIntent
|
||||
data object Withdraw : OrderIntent
|
||||
data class Rename(val productInstanceId: String) : OrderIntent
|
||||
}
|
||||
|
||||
/** Stateless evaluator of the order-conflict rules. */
|
||||
object OrderConflictRules {
|
||||
|
||||
fun resolve(intent: OrderIntent, activeOrders: List<Order>): ConflictResolution {
|
||||
val blockingOrder = activeOrders.firstOrNull { order -> blocks(intent, order) }
|
||||
return if (blockingOrder == null) ConflictResolution.Allowed else ConflictResolution.Blocked(blockingOrder)
|
||||
}
|
||||
|
||||
private fun blocks(intent: OrderIntent, order: Order): Boolean {
|
||||
if (!order.isActive) return false
|
||||
return when (intent) {
|
||||
OrderIntent.IssueCard -> order.type.isIssuing()
|
||||
OrderIntent.Withdraw -> order.type == OrderType.WITHDRAW
|
||||
is OrderIntent.Freeze -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Unfreeze -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Reissue -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Rename -> false // Rename is never blocked.
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameProductInstance(order: Order, productInstanceId: String): Boolean {
|
||||
return order.productInstanceId == productInstanceId
|
||||
}
|
||||
|
||||
private fun OrderType.isIssuing(): Boolean {
|
||||
return this == OrderType.CARD_ISSUE ||
|
||||
this == OrderType.CARD_ISSUE_ADDITIONAL ||
|
||||
this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2
|
||||
}
|
||||
|
||||
private fun OrderType.isFreezeOrReissue(): Boolean {
|
||||
return this == OrderType.CARD_FREEZE ||
|
||||
this == OrderType.CARD_UNFREEZE ||
|
||||
this == OrderType.CARD_REISSUE
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,11 @@ enum class OrderStatus {
|
|||
PROCESSING,
|
||||
COMPLETED,
|
||||
CANCELED,
|
||||
}
|
||||
;
|
||||
|
||||
val OrderStatus.isFinalStatus
|
||||
get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED
|
||||
/** An order is active while it is still being processed (NEW or PROCESSING). */
|
||||
val isActive: Boolean get() = this == NEW || this == PROCESSING
|
||||
|
||||
/** Terminal statuses (COMPLETED or CANCELED) — used to invalidate the local order hint. */
|
||||
val isTerminal: Boolean get() = this == COMPLETED || this == CANCELED
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import com.tangem.domain.pay.model.OrderType.Companion.fromString
|
||||
|
||||
/**
|
||||
* Order type used for findOrders filtering and order-conflict checks.
|
||||
*
|
||||
* Backend wire values are mapped via [fromString]; unknown values resolve to [UNKNOWN]
|
||||
* so the app never crashes on a new server-side type.
|
||||
*/
|
||||
enum class OrderType(val wireValue: String) {
|
||||
CARD_ISSUE("CARD_ISSUE_VIRTUAL_RAIN_KYC"),
|
||||
CARD_ISSUE_ADDITIONAL("CARD_ISSUE_ADDITIONAL"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"),
|
||||
CARD_REISSUE("CARD_REISSUE"),
|
||||
CARD_FREEZE("CARD_FREEZE"),
|
||||
CARD_UNFREEZE("CARD_UNFREEZE"),
|
||||
WITHDRAW("WITHDRAW"),
|
||||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): OrderType {
|
||||
if (value.isNullOrBlank()) return UNKNOWN
|
||||
return entries.firstOrNull { it.wireValue == value || it.name == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.domain.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
* Repository for `GET /v1/customer/offers`.
|
||||
*
|
||||
* Used by the issue-additional-card flow to:
|
||||
* - check whether the additional-card offer is available;
|
||||
* - drive the popup amount via [Offer.fee].
|
||||
*/
|
||||
interface CustomerOffersRepository {
|
||||
|
||||
suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>>
|
||||
}
|
||||
|
|
@ -2,10 +2,39 @@ package com.tangem.domain.pay.repository
|
|||
|
||||
import arrow.core.Either
|
||||
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.visa.error.VisaApiError
|
||||
|
||||
interface CustomerOrderRepository {
|
||||
|
||||
suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderData>
|
||||
|
||||
/**
|
||||
* Find orders matching the given filters.
|
||||
*
|
||||
* This is the source of truth for resolving active orders — a locally stored `orderId` is only a hint.
|
||||
*
|
||||
* @param types order types to include; pass an empty set for "any type".
|
||||
* @param statuses statuses to include; pass an empty set for "any status". Use `{NEW, PROCESSING}`
|
||||
* (i.e. [OrderStatus.isActive]) for active-only queries.
|
||||
*/
|
||||
suspend fun findOrders(
|
||||
userWalletId: UserWalletId,
|
||||
types: Set<OrderType> = emptySet(),
|
||||
statuses: Set<OrderStatus> = emptySet(),
|
||||
): Either<VisaApiError, List<Order>>
|
||||
|
||||
/**
|
||||
* Create a new order via `POST /v1/order` with a per-attempt idempotency key.
|
||||
* The caller is responsible for finding an existing active order before creating a new one.
|
||||
*/
|
||||
suspend fun createOrder(
|
||||
userWalletId: UserWalletId,
|
||||
type: OrderType,
|
||||
specificationName: String,
|
||||
idempotencyKey: String,
|
||||
): Either<VisaApiError, Order>
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.ConflictResolution
|
||||
import com.tangem.domain.pay.model.OrderConflictRules
|
||||
import com.tangem.domain.pay.model.OrderIntent
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
* Evaluates whether a user-driven [OrderIntent] is allowed given the currently active orders.
|
||||
*
|
||||
* Re-fetches active orders before evaluating so the decision uses up-to-date server state, not a
|
||||
* stale UI cache. UI is expected to call this immediately before triggering the action.
|
||||
*/
|
||||
class CheckOrderConflictUseCase(
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
intent: OrderIntent,
|
||||
): Either<VisaApiError, ConflictResolution> {
|
||||
return customerOrderRepository
|
||||
.findOrders(userWalletId = userWalletId, statuses = ACTIVE_STATUSES)
|
||||
.map { orders -> OrderConflictRules.resolve(intent = intent, activeOrders = orders) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.pay.repository.CustomerOffersRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
* Loads customer offers from `GET /v1/customer/offers`.
|
||||
*
|
||||
* Used by the issue-additional-card flow to gate the "+" action and to drive the cost popup.
|
||||
*/
|
||||
class GetCustomerOffersUseCase(
|
||||
private val customerOffersRepository: CustomerOffersRepository,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>> {
|
||||
return customerOffersRepository.getOffers(userWalletId)
|
||||
}
|
||||
|
||||
suspend fun additionalCardOffer(userWalletId: UserWalletId): Either<VisaApiError, Offer?> {
|
||||
return customerOffersRepository.getOffers(userWalletId).map { offers ->
|
||||
offers.firstOrNull { it.type == Offer.Type.CARD_ISSUE_VIRTUAL_RAIN }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.repository.CustomerOffersRepository
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.util.OrderResolver
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Orchestrates the issue-additional-card flow. The use case is idempotent and resume-safe:
|
||||
*
|
||||
* 1. Eligibility — fetches the customer's offers and confirms a [Offer.Type.CARD_ISSUE_VIRTUAL_RAIN]
|
||||
* offer is available; otherwise returns [VisaApiError.Unspecified].
|
||||
* 2. Resume — looks up active orders of the offer's [Offer.Data.orderType] and, if one is in flight,
|
||||
* returns it instead of creating a duplicate (find-before-create).
|
||||
* 3. Create — otherwise issues `POST /v1/order` with the offer's specification name and a fresh
|
||||
* idempotency key; backend failures propagate as [Either.Left].
|
||||
*
|
||||
* Non-fatal exceptions from either repository are logged and collapsed to [VisaApiError.Unspecified]
|
||||
* so the caller always receives a typed [Either].
|
||||
*
|
||||
* @property customerOffersRepository source of the customer's currently available offers.
|
||||
* @property customerOrderRepository used to look up active orders and create new ones.
|
||||
*/
|
||||
class IssueAdditionalCardUseCase(
|
||||
private val customerOffersRepository: CustomerOffersRepository,
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Result> = either {
|
||||
val offer = catch(
|
||||
block = {
|
||||
customerOffersRepository.getOffers(userWalletId)
|
||||
.bind()
|
||||
.firstOrNull { it.type == Offer.Type.CARD_ISSUE_VIRTUAL_RAIN }
|
||||
},
|
||||
catch = { handleError(it) },
|
||||
) ?: raise(VisaApiError.Unspecified)
|
||||
|
||||
val activeOrders = catch(
|
||||
block = {
|
||||
customerOrderRepository
|
||||
.findOrders(userWalletId = userWalletId, types = setOf(offer.data.orderType))
|
||||
.bind()
|
||||
},
|
||||
catch = { handleError(it) },
|
||||
)
|
||||
|
||||
val existing = OrderResolver.selectActive(orders = activeOrders, type = offer.data.orderType)
|
||||
val order = existing ?: customerOrderRepository.createOrder(
|
||||
userWalletId = userWalletId,
|
||||
type = offer.data.orderType,
|
||||
specificationName = offer.data.specificationName,
|
||||
idempotencyKey = UUID.randomUUID().toString(),
|
||||
).bind()
|
||||
|
||||
Result(order = order, offer = offer)
|
||||
}
|
||||
|
||||
private fun Raise<VisaApiError>.handleError(throwable: Throwable): Nothing {
|
||||
TangemLogger.e("Error in IssueAdditionalCardUseCase", throwable)
|
||||
raise(VisaApiError.Unspecified)
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a successful run.
|
||||
*
|
||||
|
||||
* @property offer the offer that authorised issuance, carried back so the caller can show pricing
|
||||
* without an extra round trip.
|
||||
*/
|
||||
data class Result(val order: Order, val offer: Offer)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
|
||||
* same customer.
|
||||
*
|
||||
* Wraps `findOrders` (the source of truth) and filters to the active set (NEW / PROCESSING).
|
||||
* The caller decides how to dispatch each order to the appropriate flow.
|
||||
*/
|
||||
class RestoreActiveOrdersUseCase(
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, List<Order>> {
|
||||
return customerOrderRepository.findOrders(
|
||||
userWalletId = userWalletId,
|
||||
statuses = ACTIVE_STATUSES,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.model.isFinalStatus
|
||||
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
|
@ -14,13 +13,13 @@ class StartTangemPayOrderPollingUseCase(
|
|||
) {
|
||||
suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean {
|
||||
while (true) {
|
||||
val newOrder = if (order.orderStatus.isFinalStatus) {
|
||||
val newOrder = if (order.orderStatus.isTerminal) {
|
||||
order
|
||||
} else {
|
||||
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
|
||||
}
|
||||
|
||||
if (newOrder != null && newOrder.orderStatus.isFinalStatus) {
|
||||
if (newOrder != null && newOrder.orderStatus.isTerminal) {
|
||||
paymentAccountStatusFetcher.invoke(userWalletId)
|
||||
return newOrder.orderStatus == OrderStatus.COMPLETED
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
* Validates a locally stored `orderId` hint before reusing it.
|
||||
*
|
||||
* - If the hint is still active → returns the order data.
|
||||
* - If the hint is terminal → clears the hint and returns null.
|
||||
*
|
||||
* The caller decides whether to fall back to `findOrders` to recover the real state.
|
||||
*/
|
||||
class ValidateLocalOrderHintUseCase(
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
private val onboardingRepository: OnboardingRepository,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, OrderData?> {
|
||||
val orderId = onboardingRepository.getOrderId(userWalletId) ?: return Either.Right(null)
|
||||
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = orderId)
|
||||
.map { data ->
|
||||
if (data.status.isTerminal) {
|
||||
onboardingRepository.clearOrderId(userWalletId)
|
||||
null
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.tangem.domain.pay.util
|
||||
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
|
||||
/**
|
||||
* Deterministic order selection:
|
||||
* 1. filter by [type];
|
||||
* 2. if a card is in scope, filter by `cardId` (or `productInstanceId` when `cardId` is missing);
|
||||
* 3. pick the latest by `updatedAt` (lexicographic ISO-8601 compare).
|
||||
*
|
||||
* Returns `null` when no order matches.
|
||||
*/
|
||||
object OrderResolver {
|
||||
|
||||
fun selectActive(
|
||||
orders: List<Order>,
|
||||
type: OrderType,
|
||||
cardId: String? = null,
|
||||
productInstanceId: String? = null,
|
||||
): Order? {
|
||||
return orders
|
||||
.asSequence()
|
||||
.filter { it.isActive }
|
||||
.filter { it.type == type }
|
||||
.filter { matchesCard(it, cardId, productInstanceId) }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
}
|
||||
|
||||
fun selectLatest(
|
||||
orders: List<Order>,
|
||||
type: OrderType,
|
||||
cardId: String? = null,
|
||||
productInstanceId: String? = null,
|
||||
): Order? {
|
||||
return orders
|
||||
.asSequence()
|
||||
.filter { it.type == type }
|
||||
.filter { matchesCard(it, cardId, productInstanceId) }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
}
|
||||
|
||||
private fun matchesCard(order: Order, cardId: String?, productInstanceId: String?): Boolean {
|
||||
// No card scope requested → any card matches.
|
||||
if (cardId == null && productInstanceId == null) return true
|
||||
// Card-scope requested but order isn't card-scoped → no match.
|
||||
if (!order.isCardScoped) return false
|
||||
if (cardId != null && order.cardId != null) return order.cardId == cardId
|
||||
if (productInstanceId != null) return order.productInstanceId == productInstanceId
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class OrderConflictRulesTest {
|
||||
|
||||
private val cardA = "cardA"
|
||||
private val cardB = "cardB"
|
||||
|
||||
@Test
|
||||
fun `IssueCard is blocked by an active issue order`() {
|
||||
val active = listOf(order(type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING))
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IssueCard is blocked by an active additional-issue order`() {
|
||||
val active = listOf(order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW))
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IssueCard is allowed when only withdraw is active`() {
|
||||
val active = listOf(order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING))
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)
|
||||
|
||||
assertThat(resolution).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Freeze on cardA is blocked by an active freeze on cardA`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Freeze(cardA), active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Freeze on cardA is allowed when freeze on cardB is active`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardB),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Freeze(cardA), active)
|
||||
|
||||
assertThat(resolution).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Unfreeze on cardA is blocked by an active reissue on cardA`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_REISSUE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Unfreeze(cardA), active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Reissue on cardA is blocked by an active freeze on cardA`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Reissue(cardA), active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Withdraw is blocked by an active withdraw`() {
|
||||
val active = listOf(order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING))
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Withdraw, active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Withdraw is allowed by an active card-scoped freeze`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Withdraw, active)
|
||||
|
||||
assertThat(resolution).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Rename is never blocked`() {
|
||||
val active = listOf(
|
||||
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
|
||||
order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING),
|
||||
order(type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.Rename(cardA), active)
|
||||
|
||||
assertThat(resolution).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Terminal-status orders never block`() {
|
||||
val terminal = listOf(
|
||||
order(type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED),
|
||||
order(type = OrderType.CARD_ISSUE, status = OrderStatus.CANCELED),
|
||||
)
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, terminal)
|
||||
|
||||
assertThat(resolution).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
private fun order(
|
||||
type: OrderType,
|
||||
status: OrderStatus,
|
||||
productInstanceId: String? = null,
|
||||
): Order = Order(
|
||||
id = "id-$type-$status",
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = productInstanceId,
|
||||
paymentAccountId = null,
|
||||
// Mirrors production: the v1 order response has no card id; conflicts match by productInstanceId.
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.*
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class CheckOrderConflictUseCaseTest {
|
||||
|
||||
private val repository: CustomerOrderRepository = mockk()
|
||||
private val useCase = CheckOrderConflictUseCase(repository)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
|
||||
@Test
|
||||
fun `WHEN no active orders THEN returns Allowed`() = runTest {
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = ACTIVE_STATUSES) } returns
|
||||
emptyList<Order>().right()
|
||||
|
||||
val result = useCase(userWalletId, OrderIntent.IssueCard)
|
||||
|
||||
assertThat(result.getOrNull()).isEqualTo(ConflictResolution.Allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN active issue order exists AND intent is IssueCard THEN returns Blocked`() = runTest {
|
||||
val activeIssue = order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.PROCESSING)
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = ACTIVE_STATUSES) } returns
|
||||
listOf(activeIssue).right()
|
||||
|
||||
val result = useCase(userWalletId, OrderIntent.IssueCard)
|
||||
|
||||
val resolution = result.getOrNull()
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
assertThat((resolution as ConflictResolution.Blocked).blockingOrder).isEqualTo(activeIssue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN repository fails THEN returns Either Left`() = runTest {
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = ACTIVE_STATUSES) } returns
|
||||
VisaApiError.Unspecified.left()
|
||||
|
||||
val result = useCase(userWalletId, OrderIntent.IssueCard)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
}
|
||||
|
||||
private fun order(type: OrderType, status: OrderStatus): Order = Order(
|
||||
id = "id",
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = null,
|
||||
paymentAccountId = null,
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import com.tangem.domain.pay.repository.CustomerOffersRepository
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
internal class IssueAdditionalCardUseCaseTest {
|
||||
|
||||
private val offersRepository: CustomerOffersRepository = mockk()
|
||||
private val orderRepository: CustomerOrderRepository = mockk()
|
||||
private val useCase = IssueAdditionalCardUseCase(offersRepository, orderRepository)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
private val spec = "SP_000004"
|
||||
|
||||
private val offer = Offer(
|
||||
type = Offer.Type.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
fee = Offer.Fee(amount = BigDecimal("1.00"), currency = Currency.getInstance("USD")),
|
||||
data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_ADDITIONAL),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `WHEN no additional-card offer is available THEN returns Unspecified error`() = runTest {
|
||||
coEvery { offersRepository.getOffers(userWalletId) } returns emptyList<Offer>().right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
coVerify(exactly = 0) { orderRepository.findOrders(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { orderRepository.createOrder(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN active issue order exists THEN reuses it without calling createOrder`() = runTest {
|
||||
val existing = order(
|
||||
id = "existing",
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
status = OrderStatus.PROCESSING,
|
||||
)
|
||||
coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right()
|
||||
coEvery {
|
||||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL),
|
||||
statuses = emptySet(),
|
||||
)
|
||||
} returns listOf(existing).right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
val resultValue = result.getOrNull()
|
||||
assertThat(resultValue?.order).isEqualTo(existing)
|
||||
assertThat(resultValue?.offer).isEqualTo(offer)
|
||||
coVerify(exactly = 0) { orderRepository.createOrder(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN backend returns insufficient balance THEN propagates CardIssueInsufficientBalance`() = runTest {
|
||||
coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right()
|
||||
coEvery {
|
||||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL),
|
||||
statuses = emptySet(),
|
||||
)
|
||||
} returns emptyList<Order>().right()
|
||||
coEvery {
|
||||
orderRepository.createOrder(
|
||||
userWalletId = userWalletId,
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
specificationName = spec,
|
||||
idempotencyKey = any(),
|
||||
)
|
||||
} returns VisaApiError.CardIssueInsufficientBalance.left()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.CardIssueInsufficientBalance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN no active order AND createOrder succeeds THEN returns the new order`() = runTest {
|
||||
coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right()
|
||||
coEvery {
|
||||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL),
|
||||
statuses = emptySet(),
|
||||
)
|
||||
} returns emptyList<Order>().right()
|
||||
val newOrder = order(
|
||||
id = "new",
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
status = OrderStatus.NEW,
|
||||
)
|
||||
coEvery {
|
||||
orderRepository.createOrder(
|
||||
userWalletId = userWalletId,
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
specificationName = spec,
|
||||
idempotencyKey = any(),
|
||||
)
|
||||
} returns newOrder.right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()?.order).isEqualTo(newOrder)
|
||||
}
|
||||
|
||||
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
|
||||
id = id,
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = null,
|
||||
paymentAccountId = null,
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Order
|
||||
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 io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class RestoreActiveOrdersUseCaseTest {
|
||||
|
||||
private val repository: CustomerOrderRepository = mockk()
|
||||
private val useCase = RestoreActiveOrdersUseCase(repository)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
|
||||
@Test
|
||||
fun `passes only NEW and PROCESSING statuses to findOrders`() = runTest {
|
||||
val expected = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) } returns
|
||||
emptyList<Order>().right()
|
||||
|
||||
useCase(userWalletId)
|
||||
|
||||
coVerify(exactly = 1) { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns the orders found by the repository`() = runTest {
|
||||
val orders = listOf(
|
||||
order(id = "issue", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING),
|
||||
order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW),
|
||||
)
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()).containsExactlyElementsIn(orders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surfaces repository errors`() = runTest {
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns
|
||||
VisaApiError.Unspecified.left()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
}
|
||||
|
||||
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
|
||||
id = id,
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = null,
|
||||
paymentAccountId = null,
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class ValidateLocalOrderHintUseCaseTest {
|
||||
|
||||
private val orderRepository: CustomerOrderRepository = mockk()
|
||||
private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = ValidateLocalOrderHintUseCase(orderRepository, onboardingRepository)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
|
||||
@Test
|
||||
fun `WHEN no hint exists THEN returns null`() = runTest {
|
||||
coEvery { onboardingRepository.getOrderId(userWalletId) } returns null
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()).isNull()
|
||||
coVerify(exactly = 0) { orderRepository.getOrderData(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN hint points to active order THEN returns it`() = runTest {
|
||||
coEvery { onboardingRepository.getOrderId(userWalletId) } returns "order-id"
|
||||
val orderData = OrderData(customerId = "c1", status = OrderStatus.PROCESSING, withdrawTxHash = null)
|
||||
coEvery { orderRepository.getOrderData(userWalletId, "order-id") } returns orderData.right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()).isEqualTo(orderData)
|
||||
coVerify(exactly = 0) { onboardingRepository.clearOrderId(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN hint points to terminal order THEN clears hint and returns null`() = runTest {
|
||||
coEvery { onboardingRepository.getOrderId(userWalletId) } returns "order-id"
|
||||
val terminal = OrderData(customerId = "c1", status = OrderStatus.COMPLETED, withdrawTxHash = null)
|
||||
coEvery { orderRepository.getOrderData(userWalletId, "order-id") } returns terminal.right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()).isNull()
|
||||
coVerify(exactly = 1) { onboardingRepository.clearOrderId(userWalletId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN repository fails THEN propagates error and does not clear hint`() = runTest {
|
||||
coEvery { onboardingRepository.getOrderId(userWalletId) } returns "order-id"
|
||||
coEvery { orderRepository.getOrderData(userWalletId, "order-id") } returns VisaApiError.Unspecified.left()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
coVerify(exactly = 0) { onboardingRepository.clearOrderId(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.tangem.domain.pay.util
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class OrderResolverTest {
|
||||
|
||||
@Test
|
||||
fun `selectActive filters by type and active status`() {
|
||||
val orders = listOf(
|
||||
order(id = "1", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-01"),
|
||||
order(id = "2", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"),
|
||||
order(id = "3", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-03"),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE)
|
||||
|
||||
assertThat(result?.id).isEqualTo("2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActive picks the latest by updatedAt`() {
|
||||
val orders = listOf(
|
||||
order(id = "old", type = OrderType.CARD_ISSUE, status = OrderStatus.NEW, updatedAt = "2026-01-01"),
|
||||
order(id = "new", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING, updatedAt = "2026-06-05"),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE)
|
||||
|
||||
assertThat(result?.id).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActive returns null when no active order of the type exists`() {
|
||||
val orders = listOf(
|
||||
order(id = "1", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-01"),
|
||||
order(id = "2", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActive scopes by productInstanceId`() {
|
||||
val orders = listOf(
|
||||
order(
|
||||
id = "card-a",
|
||||
type = OrderType.CARD_FREEZE,
|
||||
status = OrderStatus.PROCESSING,
|
||||
productInstanceId = "pi-a",
|
||||
updatedAt = "2026-01-02",
|
||||
),
|
||||
order(
|
||||
id = "card-b",
|
||||
type = OrderType.CARD_FREEZE,
|
||||
status = OrderStatus.PROCESSING,
|
||||
productInstanceId = "pi-b",
|
||||
updatedAt = "2026-01-03",
|
||||
),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectActive(
|
||||
orders = orders,
|
||||
type = OrderType.CARD_FREEZE,
|
||||
productInstanceId = "pi-a",
|
||||
)
|
||||
|
||||
assertThat(result?.id).isEqualTo("card-a")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectActive ignores payment-account-level orders when a card scope is requested`() {
|
||||
val orders = listOf(
|
||||
order(id = "account-level", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "x"),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectActive(
|
||||
orders = orders,
|
||||
type = OrderType.WITHDRAW,
|
||||
productInstanceId = "pi-a",
|
||||
)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectLatest includes terminal orders`() {
|
||||
val orders = listOf(
|
||||
order(id = "1", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-05"),
|
||||
order(id = "2", type = OrderType.CARD_ISSUE, status = OrderStatus.NEW, updatedAt = "2026-01-01"),
|
||||
)
|
||||
|
||||
val result = OrderResolver.selectLatest(orders = orders, type = OrderType.CARD_ISSUE)
|
||||
|
||||
assertThat(result?.id).isEqualTo("1")
|
||||
}
|
||||
|
||||
private fun order(
|
||||
id: String,
|
||||
type: OrderType,
|
||||
status: OrderStatus,
|
||||
productInstanceId: String? = null,
|
||||
cardId: String? = null,
|
||||
updatedAt: String? = null,
|
||||
): Order = Order(
|
||||
id = id,
|
||||
customerId = null,
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = productInstanceId,
|
||||
paymentAccountId = null,
|
||||
cardId = cardId,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ internal class TangemPayCardLimitSetupModelTest {
|
|||
|
||||
private val initialCard = TangemPayCard(
|
||||
id = cardId,
|
||||
productInstanceId = "pi_$cardId",
|
||||
cardStatus = TangemPayCard.Status.ACTIVE,
|
||||
hasPinCode = false,
|
||||
displayName = null,
|
||||
frozenState = TangemPayCardFrozenState.Unfrozen,
|
||||
|
|
@ -64,6 +66,8 @@ internal class TangemPayCardLimitSetupModelTest {
|
|||
): TangemPayCardLimitSetupModel {
|
||||
val cardWithLimit = TangemPayCard(
|
||||
id = cardId,
|
||||
productInstanceId = "pi_$cardId",
|
||||
cardStatus = TangemPayCard.Status.ACTIVE,
|
||||
hasPinCode = false,
|
||||
displayName = null,
|
||||
frozenState = TangemPayCardFrozenState.Unfrozen,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue