Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 03:05:46 -07:00
parent e3868e3ecd
commit 576332d67d
24 changed files with 250 additions and 17 deletions

View file

@ -11,7 +11,9 @@ data class OrderRequest(
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "specification_name") val specificationName: String = "SP_000004",
@Json(name = "type") val type: String = "CARD_ISSUE_VIRTUAL_RAIN_KYC",
@Json(name = "specification_name") val specificationName: String?,
@Json(name = "type") val type: String,
@Json(name = "target_tariff_plan_id") val targetTariffPlanId: String? = null,
@Json(name = "tariff_plan_transition_type") val tariffPlanTransitionType: String? = null,
)
}

View file

@ -40,6 +40,16 @@ data class CustomerMeResponse(
@Json(name = "name") val name: String?,
@Json(name = "description_items") val descriptionItems: List<DescriptionItem>?,
@Json(name = "images") val images: List<Image>? = null,
@Json(name = "fees") val fees: List<Fee>? = null,
)
@JsonClass(generateAdapter = true)
data class Fee(
@Json(name = "type") val type: String?,
@Json(name = "amount") val amount: BigDecimal?,
@Json(name = "currency") val currency: String?,
@Json(name = "description") val description: String?,
@Json(name = "period") val period: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -27,6 +27,7 @@ data class OrderResponse(
@Json(name = "emboss_name") val embossName: String?,
@Json(name = "product_instance_id") val productInstanceId: String?,
@Json(name = "payment_account_id") val paymentAccountId: String?,
@Json(name = "target_tariff_plan_id") val targetTariffPlanId: String?,
@Json(name = "transaction_hash") val transactionHash: String?,
)

View file

@ -14,6 +14,18 @@ internal object TangemPayTariffPlanConverter {
name = name,
descriptionItems = value.descriptionItems.orEmpty().mapNotNull(::convertDescriptionItem),
images = value.images.orEmpty().mapNotNull(::convertImage),
fees = value.fees.orEmpty().mapNotNull(::convertFee),
)
}
private fun convertFee(fee: CustomerMeResponse.Fee): TangemPayTariffPlan.Fee? {
val amount = fee.amount ?: return null
return TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.fromString(fee.type),
amount = amount,
currency = fee.currency.orEmpty(),
description = fee.description.orEmpty(),
period = fee.period?.let(TangemPayTariffPlan.Fee.Period::fromString),
)
}

View file

@ -139,6 +139,17 @@ internal interface TangemPayDataModule {
return GetTangemPayTariffPlanTransitionsUseCase(repository)
}
@Provides
fun provideGetTangemPayTariffPlanStateUseCase(
customerOrderRepository: CustomerOrderRepository,
getTangemPayTariffPlanTransitionsUseCase: GetTangemPayTariffPlanTransitionsUseCase,
): GetTangemPayTariffPlanStateUseCase {
return GetTangemPayTariffPlanStateUseCase(
customerOrderRepository = customerOrderRepository,
getTariffPlanTransitions = getTangemPayTariffPlanTransitionsUseCase,
)
}
@Provides
@Singleton
fun providePaymentAccountStatusesStore(

View file

@ -17,6 +17,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDat
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.pay.repository.*
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
@ -63,6 +64,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -395,7 +397,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
),
error = null,
virtualAccount = virtualAccount,
tariffPlan = tariffPlan,
tariffPlan = tariffPlan?.let { tariff ->
getTangemPayTariffPlanStateUseCase(
userWalletId = userWalletId,
tariff = tariff,
)
},
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.data.pay.util.OrderConverter
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderData
@ -53,8 +54,10 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
override suspend fun createOrder(
userWalletId: UserWalletId,
type: OrderType,
specificationName: String,
specificationName: String?,
idempotencyKey: String,
targetTariffPlanId: String?,
transitionType: TangemPayTariffPlanTransition.Type?,
): Either<VisaApiError, Order> {
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
return requestHelper.performRequest(userWalletId) { authHeader ->
@ -65,6 +68,8 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
customerWalletAddress = walletAddress,
specificationName = specificationName,
type = type.wireValue,
targetTariffPlanId = targetTariffPlanId,
tariffPlanTransitionType = transitionType?.name,
),
idempotencyKey = idempotencyKey,
),

View file

@ -29,6 +29,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
@ -161,7 +162,11 @@ internal class DefaultOnboardingRepository @Inject constructor(
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
requestHelper.performRequest(userWalletId) { authHeader ->
val data = OrderRequest.Data(customerWalletAddress = walletAddress)
val data = OrderRequest.Data(
customerWalletAddress = walletAddress,
specificationName = "SP_000004",
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC.wireValue,
)
tangemPayApi.createOrder(
authHeader = authHeader,
body = OrderRequest(data = data, idempotencyKey = UUID.randomUUID().toString()),

View file

@ -2,6 +2,7 @@ package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStep
import com.tangem.domain.pay.model.OrderType
/** Maps a wire `OrderResponse.Result` into the domain [Order] model. */
@ -15,11 +16,12 @@ internal object OrderConverter {
customerId = value.customerId,
type = type,
status = status,
step = value.step,
step = OrderStep.fromString(value.step),
stepChangeCode = value.stepChangeCode,
productInstanceId = value.data.productInstanceId,
paymentAccountId = value.data.paymentAccountId,
cardId = null, // Card id not in v1 response shape; resolved via productInstanceId.
toTariffPlanId = value.data.targetTariffPlanId,
withdrawTxHash = value.data.transactionHash?.ifEmpty { null },
createdAt = value.createdAt,
updatedAt = value.updatedAt,

View file

@ -19,6 +19,7 @@ import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
@ -48,6 +49,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk()
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase = mockk()
private val fetcher = DefaultPaymentAccountStatusFetcher(
paymentAccountStatusesStore = paymentAccountStatusesStore,
@ -63,6 +65,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
)
private val userWalletId = UserWalletId("011")

View file

@ -166,7 +166,7 @@ sealed class PaymentAccountStatusValue {
val fiatRate: SerializedBigDecimal?,
val error: Error?,
val virtualAccount: VirtualAccountOnramp?,
val tariffPlan: TangemPayCustomerTariffPlan?,
val tariffPlan: TangemPayTariffPlanState?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,

View file

@ -1,5 +1,6 @@
package com.tangem.domain.models.account
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
@ -11,7 +12,55 @@ data class TangemPayTariffPlan(
@SerialName("name") val name: String,
@SerialName("description_items") val descriptionItems: List<DescriptionItem>,
@SerialName("images") val images: List<Image> = emptyList(),
@SerialName("fees") val fees: List<Fee> = emptyList(),
) {
@Serializable
data class Fee(
@SerialName("type") val type: Type,
@SerialName("amount") val amount: SerializedBigDecimal,
@SerialName("currency") val currency: String,
@SerialName("description") val description: String,
@SerialName("period") val period: Period?,
) {
@Serializable
enum class Type {
@SerialName("FREE")
FREE,
@SerialName("RECURRING")
RECURRING,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"FREE" -> FREE
"RECURRING" -> RECURRING
else -> UNKNOWN
}
}
}
@Serializable
enum class Period {
@SerialName("MONTH")
MONTH,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"MONTH" -> MONTH
else -> UNKNOWN
}
}
}
}
@Serializable
data class DescriptionItem(
@SerialName("section") val section: Section,

View file

@ -0,0 +1,29 @@
package com.tangem.domain.models.account
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TangemPayTariffPlanState(
@SerialName("tariff") val tariff: TangemPayCustomerTariffPlan,
@SerialName("order") val order: Order?,
) {
@Serializable
data class Order(
@SerialName("orderId") val orderId: String,
@SerialName("step") val step: OrderStep,
)
@Serializable
sealed interface OrderStep {
@Serializable
data object Unknown : OrderStep
@Serializable
data class AwaitingDeposit(
@SerialName("fromPlan") val fromPlan: TangemPayTariffPlan,
@SerialName("toPlan") val toPlan: TangemPayTariffPlan,
) : OrderStep
}
}

View file

@ -14,6 +14,7 @@ package com.tangem.domain.pay.model
* @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 toTariffPlanId target tariff plan id for [OrderType.TARIFF_PLAN_TRANSITION] orders.
* @property withdrawTxHash present for completed [OrderType.WITHDRAW] orders.
* @property updatedAt ISO-8601 timestamp used to pick the most recent matching order.
*/
@ -22,11 +23,12 @@ data class Order(
val customerId: String?,
val type: OrderType,
val status: OrderStatus,
val step: String?,
val step: OrderStep,
val stepChangeCode: Int?,
val productInstanceId: String?,
val paymentAccountId: String?,
val cardId: String?,
val toTariffPlanId: String?,
val withdrawTxHash: String?,
val createdAt: String?,
val updatedAt: String?,

View file

@ -0,0 +1,16 @@
package com.tangem.domain.pay.model
import java.util.Locale
enum class OrderStep {
AWAITING_DEPOSIT,
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"AWAITING_DEPOSIT" -> AWAITING_DEPOSIT
else -> UNKNOWN
}
}
}

View file

@ -18,6 +18,7 @@ enum class OrderType(val wireValue: String) {
CARD_FREEZE("CARD_FREEZE"),
CARD_UNFREEZE("CARD_UNFREEZE"),
WITHDRAW("WITHDRAW"),
TARIFF_PLAN_TRANSITION("TARIFF_PLAN_TRANSITION"),
UNKNOWN(""),
;

View file

@ -1,6 +1,7 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderData
@ -34,7 +35,9 @@ interface CustomerOrderRepository {
suspend fun createOrder(
userWalletId: UserWalletId,
type: OrderType,
specificationName: String,
specificationName: String?,
idempotencyKey: String,
targetTariffPlanId: String? = null,
transitionType: TangemPayTariffPlanTransition.Type? = null,
): Either<VisaApiError, Order>
}

View file

@ -0,0 +1,63 @@
package com.tangem.domain.pay.usecase
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState
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.OrderStep
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.util.OrderResolver
class GetTangemPayTariffPlanStateUseCase(
private val customerOrderRepository: CustomerOrderRepository,
private val getTariffPlanTransitions: GetTangemPayTariffPlanTransitionsUseCase,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
tariff: TangemPayCustomerTariffPlan,
): TangemPayTariffPlanState {
val order = findTariffPlanOrder(userWalletId)
return TangemPayTariffPlanState(
tariff = tariff,
order = order?.let { activeOrder ->
TangemPayTariffPlanState.Order(
orderId = activeOrder.id,
step = resolveStep(
userWalletId = userWalletId,
tariff = tariff,
order = activeOrder,
),
)
},
)
}
private suspend fun findTariffPlanOrder(userWalletId: UserWalletId): Order? {
return customerOrderRepository.findOrders(
userWalletId = userWalletId,
types = setOf(OrderType.TARIFF_PLAN_TRANSITION),
statuses = OrderStatus.activeStatuses,
).getOrNull()?.let { orders ->
OrderResolver.selectActive(orders = orders, type = OrderType.TARIFF_PLAN_TRANSITION)
}
}
private suspend fun resolveStep(
userWalletId: UserWalletId,
tariff: TangemPayCustomerTariffPlan,
order: Order,
): TangemPayTariffPlanState.OrderStep {
if (order.step != OrderStep.AWAITING_DEPOSIT) return TangemPayTariffPlanState.OrderStep.Unknown
val transition = getTariffPlanTransitions(userWalletId).getOrNull()
?.firstOrNull { it.plan.id == order.toTariffPlanId }
?: return TangemPayTariffPlanState.OrderStep.Unknown
return TangemPayTariffPlanState.OrderStep.AwaitingDeposit(
fromPlan = tariff.plan,
toPlan = transition.plan,
)
}
}

View file

@ -142,12 +142,13 @@ internal class OrderConflictRulesTest {
customerId = "customer",
type = type,
status = status,
step = null,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = productInstanceId,
paymentAccountId = null,
// Mirrors production: the v1 order response has no card id; conflicts match by productInstanceId.
cardId = null,
toTariffPlanId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,

View file

@ -56,11 +56,12 @@ internal class CheckOrderConflictUseCaseTest {
customerId = "customer",
type = type,
status = status,
step = null,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
toTariffPlanId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,

View file

@ -7,6 +7,7 @@ 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.OrderStep
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOffersRepository
import com.tangem.domain.pay.repository.CustomerOrderRepository
@ -51,7 +52,9 @@ internal class IssueAdditionalCardUseCaseTest {
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { orderRepository.findOrders(any(), any(), any()) }
coVerify(exactly = 0) { orderRepository.createOrder(any(), any(), any(), any()) }
coVerify(exactly = 0) {
orderRepository.createOrder(any(), any(), any(), any(), any(), any())
}
}
@Test
@ -77,7 +80,9 @@ internal class IssueAdditionalCardUseCaseTest {
val result = useCase(userWalletId)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { orderRepository.createOrder(any(), any(), any(), any()) }
coVerify(exactly = 0) {
orderRepository.createOrder(any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, existing.id) }
}
@ -148,11 +153,12 @@ internal class IssueAdditionalCardUseCaseTest {
customerId = "customer",
type = type,
status = status,
step = null,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
toTariffPlanId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,

View file

@ -6,6 +6,7 @@ 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.OrderStep
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
@ -113,11 +114,12 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
customerId = "customer",
type = type,
status = status,
step = null,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
toTariffPlanId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,

View file

@ -3,6 +3,7 @@ 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.OrderStep
import com.tangem.domain.pay.model.OrderType
import org.junit.jupiter.api.Test
@ -112,11 +113,12 @@ internal class OrderResolverTest {
customerId = null,
type = type,
status = status,
step = null,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = productInstanceId,
paymentAccountId = null,
cardId = cardId,
toTariffPlanId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = updatedAt,

View file

@ -75,7 +75,7 @@ internal class TangemPayDetailsStateFactory(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = status.tariffPlan),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = status.tariffPlan?.tariff),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,