Updated on 2026-08-14

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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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>>
}

View file

@ -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>
}

View file

@ -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)
}
}

View file

@ -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 }
}
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}

View file

@ -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,
)
}

View file

@ -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)
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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()) }
}
}

View file

@ -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,
)
}