Updated on 2026-08-14

This commit is contained in:
Tangem 2026-08-03 05:16:00 -07:00
parent 815f6c2b25
commit 95ba8be221
8 changed files with 116 additions and 12 deletions

View file

@ -261,7 +261,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold(
ifLeft = { error ->
logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error")
error.toStatusValueWhenHasTangemPay(account.userWalletId)
if (error is VisaApiError.OrderNotFound) {
handleOrderNotFound(account = account)
} else {
error.toStatusValueWhenHasTangemPay(account.userWalletId)
}
},
ifRight = { orderData ->
logger.i("proceedWithOrderId ${account.userWalletId}: $orderId status: ${orderData.status}")
@ -298,6 +302,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
result.fold(
ifLeft = { error ->
logger.e("pollOrderStatus ${account.userWalletId} orderId: $orderId error: $error")
if (error is VisaApiError.OrderNotFound) {
return handleOrderNotFound(account = account)
}
// Continue polling on transient errors
},
ifRight = { orderData ->
@ -316,6 +323,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
private suspend fun handleOrderNotFound(account: Account.Payment): PaymentAccountStatusValue {
onboardingRepository.clearOrderId(account.userWalletId)
return proceedWithoutOrder(account = account)
}
private suspend fun handleCanceledOrder(
account: Account.Payment,
orderData: OrderData,
@ -470,8 +482,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
* (idempotent) and eagerly fetches its bank credentials ([VirtualAccountOnramp.Available], or
* [VirtualAccountOnramp.BankCredentialsError] on failure).
* 2. Otherwise, a VA order id is persisted locally checks its status via `getOrderData`:
* NEW/PROCESSING/COMPLETED (or a lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
* clears the persisted id and falls through to eligibility.
* NEW/PROCESSING/COMPLETED (or a transient lookup failure) surface [VirtualAccountOnramp.Processing]; CANCELED
* or a [VisaApiError.OrderNotFound] (the persisted id went stale) clears the persisted id and falls through
* to eligibility.
* 3. Otherwise (or after a CANCELED order) surfaces [VirtualAccountOnramp.Eligible] when the wallet has
* the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else `null`.
*/
@ -503,7 +516,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return customerOrderRepository.getOrderData(userWalletId = userWalletId, orderId = vaOrderId).fold(
ifLeft = { error ->
logger.e("getOrderData(va) failed for $vaOrderId: $error")
VirtualAccountOnramp.Processing
if (error is VisaApiError.OrderNotFound) {
onboardingRepository.clearVirtualAccountOrderId(userWalletId)
resolveEligibility(userWalletId)
} else {
VirtualAccountOnramp.Processing
}
},
ifRight = { orderData ->
when (orderData.status) {
@ -614,7 +632,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
userWalletId: UserWalletId,
): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId)
is VisaApiError.NotFound -> constructNotCreatedOrEmptyStatus(userWalletId)
else -> toErrorValue()
}
}
@ -623,7 +641,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
userWalletId: UserWalletId,
): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId)
is VisaApiError.NotFound -> constructNotCreatedOrEmptyStatus(userWalletId)
else -> {
val previousValue = paymentAccountStatusesStore.getSyncOrNull(userWalletId)?.value
if (previousValue != null && previousValue.hasAccountData()) {

View file

@ -23,6 +23,8 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
override suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderData> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.mapLeft { error ->
if (error is VisaApiError.NotFound) VisaApiError.OrderNotFound else error
}.map { response ->
val status = response.result?.status?.let(OrderStatusConverter::convert) ?: OrderStatus.PROCESSING
OrderData(

View file

@ -264,7 +264,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId, shouldShowTangemPayBlock)
shouldShowTangemPayBlock
}.mapLeft { error ->
if (error is VisaApiError.NotPaeraCustomer) {
if (error is VisaApiError.NotFound) {
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId, false)
}
error

View file

@ -20,7 +20,7 @@ internal class TangemPayErrorConverter @Inject constructor(
override fun convert(value: Throwable): VisaApiError {
return if (value is ApiResponseError.HttpException) {
if (value.isServerError()) return VisaApiError.ServerUnavailable
if (value.code == ApiResponseError.HttpException.Code.NOT_FOUND) return VisaApiError.NotPaeraCustomer
if (value.code == ApiResponseError.HttpException.Code.NOT_FOUND) return VisaApiError.NotFound
if (value.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) return VisaApiError.RefreshTokenExpired
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode

View file

@ -22,7 +22,7 @@ import javax.inject.Singleton
* INACTIVE, balances ([getCustomerInfo]) is driven by the WireMock test scenario (authenticated with the
* synthetic tokens from [com.tangem.data.pay.store.MockAwareTangemPayStorage]) rather than hardcoded:
* - [hasTangemPayInWallet] delegates to the real repo, so the "existing customer" gate follows the
* checkCustomerWalletId mock (the `tangem_pay_eligibility` scenario: `Started` 404/NotPaeraCustomer
* checkCustomerWalletId mock (the `tangem_pay_eligibility` scenario: `Started` 404/NotFound
* no Payment account, `PaeraCustomer` 200 Payment account);
* - [getCustomerInfo] delegates to the real repo (WireMock), so KYC / customer-state scenarios take effect.
*/
@ -123,7 +123,7 @@ internal class MockAwareOnboardingRepository @Inject constructor(
// The "existing Tangem Pay customer" gate (decides whether an active Payment account — and accounts mode —
// appears). Delegates to WireMock's checkCustomerWalletId via the real repo (static token, no signing), so it
// is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotPaeraCustomer → no account;
// is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotFound → no account;
// `PaeraCustomer` → 200 → account. Generic UI tests never set the scenario, so they stay Payment-free.
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)

View file

@ -89,6 +89,10 @@ internal class DefaultPaymentAccountStatusFetcherTest {
private val userWalletId = UserWalletId("011")
private val params = PaymentAccountStatusFetcher.Params(userWalletId)
private companion object {
const val STALE_ORDER_ID = "order-gone"
}
private val bankCredentialsFixture = BankCredentials(
type = "ACH",
beneficiaryName = "Test Beneficiary",
@ -469,6 +473,85 @@ internal class DefaultPaymentAccountStatusFetcherTest {
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
}
@Test
fun `GIVEN no instance and va order is gone on backend WHEN invoke THEN id cleared and falls back to eligibility`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = listOf(cardProductInstance))
stubHappyPath(customerInfo)
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns true
coEvery { onboardingRepository.getVirtualAccountOrderId(userWalletId) } returns "va-1"
coEvery {
customerOrderRepository.getOrderData(userWalletId, "va-1")
} returns VisaApiError.OrderNotFound.left()
coEvery { onboardingRepository.clearVirtualAccountOrderId(userWalletId) } just Runs
coEvery {
onboardingRepository.fetchCustomerEligibility(userWalletId)
} returns Either.Right(listOf(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT))
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
coVerify(exactly = 1) { onboardingRepository.clearVirtualAccountOrderId(userWalletId) }
assertThat(storedStatuses.lastLoaded().virtualAccount).isEqualTo(VirtualAccountOnramp.Eligible)
}
}
/**
* A locally persisted `orderId` can go stale (the order is removed on the backend) while the wallet is still a
* valid Paera customer. Before the fix, the resulting 404 was read as "not a Paera customer" and collapsed the
* whole Tangem Pay block to [PaymentAccountStatusValue.Empty] on every refresh, with the stale id never
* cleared so the card stayed invisible until app reinstall.
*/
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class StaleOrderIdRecovery {
@Test
fun `GIVEN persisted order is gone on backend WHEN invoke THEN id cleared and status rebuilt from customer info`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
coEvery { onboardingRepository.getOrderId(userWalletId) } returns STALE_ORDER_ID
coEvery {
customerOrderRepository.getOrderData(userWalletId, STALE_ORDER_ID)
} returns VisaApiError.OrderNotFound.left()
coEvery { onboardingRepository.clearOrderId(userWalletId) } just Runs
coEvery { onboardingRepository.createOrder(userWalletId) } returns "order-2".right()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
coVerify(exactly = 1) { onboardingRepository.clearOrderId(userWalletId) }
assertThat(storedStatuses.map { it.value }.last())
.isEqualTo(PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL))
}
@Test
fun `GIVEN order lookup fails transiently WHEN invoke THEN id kept and status is Unavailable`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
coEvery { onboardingRepository.getOrderId(userWalletId) } returns STALE_ORDER_ID
coEvery {
customerOrderRepository.getOrderData(userWalletId, STALE_ORDER_ID)
} returns VisaApiError.ServerUnavailable.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
coVerify(exactly = 0) { onboardingRepository.clearOrderId(userWalletId) }
assertThat(storedStatuses.map { it.value }.last())
.isEqualTo(PaymentAccountStatusValue.Error.Unavailable)
}
}
/**

View file

@ -138,7 +138,7 @@ internal class GetVirtualAccountEligibilityUseCaseTest {
coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER)
coEvery {
onboardingRepository.hasTangemPayInWallet(wallet.walletId)
} returns VisaApiError.NotPaeraCustomer.left()
} returns VisaApiError.NotFound.left()
// WHEN
val result = useCase(VirtualAccountEntryPoint.BANNER)

View file

@ -62,12 +62,13 @@ sealed class VisaApiError(
fun isUnknown() = this is UnknownWithoutCode || this is Unknown
data object RefreshTokenExpired : VisaApiError(104004001)
data object NotPaeraCustomer : VisaApiError(104004002)
data object NotFound : VisaApiError(104004002)
data object WithdrawalDataError : VisaApiError(104004003)
data object SignWithdrawError : VisaApiError(104004004)
data object WithdrawError : VisaApiError(104004005)
data object ServerUnavailable : VisaApiError(104004006)
data object CustomerIdUnavailable : VisaApiError(104004007)
data object OrderNotFound : VisaApiError(104004008)
companion object {
fun fromBackendError(backendErrorCode: Int): VisaApiError {