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