Updated on 2026-08-14
This commit is contained in:
parent
815f6c2b25
commit
95ba8be221
8 changed files with 116 additions and 12 deletions
|
|
@ -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")
|
||||
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")
|
||||
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()) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue