diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 46b35c3bce..85108219f0 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -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" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index d930dbbba1..a64c636127 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -46,12 +46,28 @@ interface TangemPayApi { @Path("order_id") orderId: String, ): ApiResponse + /** + * 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?, + @Query("order_statuses") orderStatuses: List?, + ): ApiResponse + @POST("v1/order") suspend fun createOrder( @Header("Authorization") authHeader: String, @Body body: OrderRequest, ): ApiResponse + /** Customer offers — used to gate the issue-additional-card flow. */ + @GET("v1/customer/offers") + suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse + @GET("v1/customer/balance") suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/OrderRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/OrderRequest.kt index b293b1e3aa..58213aa421 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/OrderRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/OrderRequest.kt @@ -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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 61fdefe5b2..da654f8d16 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -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, + @Json(name = "cards") val cards: List, ) @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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerOffersResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerOffersResponse.kt new file mode 100644 index 0000000000..9c5e83c7d7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerOffersResponse.kt @@ -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, +) { + + @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, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FindOrdersResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FindOrdersResponse.kt new file mode 100644 index 0000000000..b476ea955c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FindOrdersResponse.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 41b174cf8d..0aef21ab21 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -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?, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index b9cf0f60a1..3b7fe7face 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -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( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index f3cacfd560..47dbad2d31 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -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) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index e6342cdf4d..0950ff21ae 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -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 diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt new file mode 100644 index 0000000000..f50aa806e6 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt @@ -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> { + 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), + ), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt index 8871a7d804..e0f31f3e9d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt @@ -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, + statuses: Set, + ): Either> { + 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 { + 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) + } + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 1ecf466eb9..932e7041bc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -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) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index 95f70c31bc..cc7ac9f9a8 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -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 { - @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, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderConverter.kt new file mode 100644 index 0000000000..f70bef92f5 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderConverter.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt index a344236e15..028b83ca9d 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -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 \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt index be321d6b33..7a18dfb166 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt @@ -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) } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index fbc32175d5..2b96bd312f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -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, + val cards: List, 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, ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Offer.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Offer.kt new file mode 100644 index 0000000000..de711b5110 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Offer.kt @@ -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, + ) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Order.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Order.kt new file mode 100644 index 0000000000..4ad4c662f9 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/Order.kt @@ -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 +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt new file mode 100644 index 0000000000..9e8e69e94a --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt @@ -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): 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 + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 035572167a..9741d825f6 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -5,7 +5,11 @@ enum class OrderStatus { PROCESSING, COMPLETED, CANCELED, -} + ; -val OrderStatus.isFinalStatus - get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED \ No newline at end of file + /** 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 +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt new file mode 100644 index 0000000000..979f004e79 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOffersRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOffersRepository.kt new file mode 100644 index 0000000000..d1e88c19cd --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOffersRepository.kt @@ -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> +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt index 09b25edc3a..db6412e923 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CustomerOrderRepository.kt @@ -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 + + /** + * 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 = emptySet(), + statuses: Set = emptySet(), + ): Either> + + /** + * 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 } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCase.kt new file mode 100644 index 0000000000..41406f937b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCase.kt @@ -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 { + return customerOrderRepository + .findOrders(userWalletId = userWalletId, statuses = ACTIVE_STATUSES) + .map { orders -> OrderConflictRules.resolve(intent = intent, activeOrders = orders) } + } + + private companion object { + val ACTIVE_STATUSES: Set = setOf(OrderStatus.NEW, OrderStatus.PROCESSING) + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCustomerOffersUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCustomerOffersUseCase.kt new file mode 100644 index 0000000000..d736eed4c7 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCustomerOffersUseCase.kt @@ -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> { + return customerOffersRepository.getOffers(userWalletId) + } + + suspend fun additionalCardOffer(userWalletId: UserWalletId): Either { + return customerOffersRepository.getOffers(userWalletId).map { offers -> + offers.firstOrNull { it.type == Offer.Type.CARD_ISSUE_VIRTUAL_RAIN } + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt new file mode 100644 index 0000000000..e019b138e4 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt @@ -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 = 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.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) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt new file mode 100644 index 0000000000..e812258ed6 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt @@ -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> { + return customerOrderRepository.findOrders( + userWalletId = userWalletId, + statuses = ACTIVE_STATUSES, + ) + } + + private companion object { + val ACTIVE_STATUSES: Set = setOf(OrderStatus.NEW, OrderStatus.PROCESSING) + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt index 50ea3fdfd9..d5c9ef3234 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt @@ -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 } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCase.kt new file mode 100644 index 0000000000..819bb68cc0 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCase.kt @@ -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 { + 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 + } + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/util/OrderResolver.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/util/OrderResolver.kt new file mode 100644 index 0000000000..5e33fc7bc3 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/util/OrderResolver.kt @@ -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, + 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, + 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 + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt new file mode 100644 index 0000000000..5df9fc677d --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt new file mode 100644 index 0000000000..270eda776c --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt @@ -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().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 = setOf(OrderStatus.NEW, OrderStatus.PROCESSING) + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt new file mode 100644 index 0000000000..9c008202aa --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt @@ -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().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().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().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, + ) +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt new file mode 100644 index 0000000000..824337d9e4 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt @@ -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().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, + ) +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCaseTest.kt new file mode 100644 index 0000000000..35ffe0082f --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ValidateLocalOrderHintUseCaseTest.kt @@ -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()) } + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/util/OrderResolverTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/util/OrderResolverTest.kt new file mode 100644 index 0000000000..d348afc362 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/util/OrderResolverTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index 9017b9d11e..136602f6de 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -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,