From 95ba8be22143e20678f4f9cabf154d91f4c88cd0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 3 Aug 2026 05:16:00 -0700 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultPaymentAccountStatusFetcher.kt | 30 +++++-- .../DefaultCustomerOrderRepository.kt | 2 + .../repository/DefaultOnboardingRepository.kt | 2 +- .../data/pay/util/TangemPayErrorConverter.kt | 2 +- .../MockAwareOnboardingRepository.kt | 4 +- .../DefaultPaymentAccountStatusFetcherTest.kt | 83 +++++++++++++++++++ ...GetVirtualAccountEligibilityUseCaseTest.kt | 2 +- .../com/tangem/domain/visa/error/VisaError.kt | 3 +- 8 files changed, 116 insertions(+), 12 deletions(-) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index e9599a73f2..7d49c48a8b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -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()) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt index 6a945ab445..d0852c74c7 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt @@ -23,6 +23,8 @@ internal class DefaultCustomerOrderRepository @Inject constructor( override suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either { 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( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 7e4b7ceba2..c85008be48 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -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 diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt index 7034052655..996703645b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/TangemPayErrorConverter.kt @@ -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 diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index a78d8c4e21..770bd99864 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -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 = real.hasTangemPayInWallet(userWalletId) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt index 9550d03608..2c3fc07703 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -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) + } } /** diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt index 2e83b585b0..0bc5efa88d 100644 --- a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt @@ -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) diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt index 7a18dfb166..83ce68c83d 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt @@ -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 {