From b7c6cf8b52bccbbe1f2292f4c42adc090174f373 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 21:04:15 -0400 Subject: [PATCH] fix(cash-link): show "Already collected" when a claim loses the race at submit When several people claim the same cash link, the account query can still return CLAIM_STATE_NOT_CLAIMED after another claim has been accepted, so the pre-claim checks pass and SubmitIntent rejects with STALE_STATE. One Bugsnag event (6ab9844f) shows the query returning NOT_CLAIMED 17 ms before the rejection. ReceiveGiftCardTransactor passed that StaleState through, and CashLinkDelegate only matches ReceiveGiftTransactorError, so the user saw "Failed to collect". Map the "already claimed" and "expired" StaleState reasons to AlreadyClaimed and Expired, the errors the pre-claim checks already return. Other StaleState reasons still go through logAndFail. --- .../delegates/CashLinkDelegateTest.kt | 24 +++++++++++ .../transactors/ReceiveGiftCardTransactor.kt | 13 ++++-- .../ReceiveGiftCardTransactorTest.kt | 40 ++++++++++++++++--- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt index 3412720005..75c16ef281 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -11,7 +11,9 @@ import com.flipcash.app.session.SettledClaim import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.user.UserManager +import com.flipcash.core.R import com.getcode.manager.BottomBarManager +import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.CurrencyCode @@ -267,6 +269,28 @@ class CashLinkDelegateTest { ) } + @Test + fun `a claim rejected as already claimed shows the already collected alert`() = runTest { + every { resources.getString(R.string.error_title_alreadyCollected) } returns "Already Collected" + val delegate = createDelegate() + + val onError = slot<(Throwable) -> Unit>() + delegate.openCashLink("validEntropy123") + verify { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = capture(onError), + ) + } + onError.captured.invoke(ReceiveGiftTransactorError.AlreadyClaimed()) + runCurrent() + + assertEquals("Already Collected", BottomBarManager.messages.value.single().title) + } + @Test fun `openCashLink clears bottom bar before processing`() = runTest { // Add a message to BottomBarManager first diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt index f2ece09f20..203b3f9ebe 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt @@ -154,10 +154,15 @@ internal class ReceiveGiftCardTransactor( }, onFailure = { error -> onStep("intent") - if (error is SubmitIntentError.StaleState && error.isGiftCardAlreadyClaimed) { - Result.failure(error) - } else { - logAndFail(error) + // The account query can report NOT_CLAIMED after another claim + // has been accepted, so the pre-claim checks pass and the server + // rejects at submit. Map those to the same errors the checks use. + when { + error is SubmitIntentError.StaleState && error.isGiftCardAlreadyClaimed -> + Result.failure(ReceiveGiftTransactorError.AlreadyClaimed()) + error is SubmitIntentError.StaleState && error.isGiftCardExpired -> + Result.failure(ReceiveGiftTransactorError.Expired()) + else -> logAndFail(error) } } ) diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt index 416dc32d9e..3afac5e61c 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactorTest.kt @@ -21,6 +21,7 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertIs import kotlin.test.assertSame import kotlin.test.assertTrue @@ -70,20 +71,47 @@ class ReceiveGiftCardTransactorTest { // region intent submission @Test - fun `start fails with the stale state when the gift card was already claimed`() = runTest { + fun `start fails with AlreadyClaimed when the intent is rejected as already claimed`() = runTest { + // The account query can still report NOT_CLAIMED when another claim has + // just been accepted, so the pre-claim check passes and submit is rejected. setupWithOwner() setupClaimableGiftCard() - val alreadyClaimed = SubmitIntentError.StaleState( - listOf("gift card balance has already been claimed") + coEvery { + transactionController.receiveRemotely(any(), any(), any(), any()) + } returns Result.failure( + SubmitIntentError.StaleState(listOf("gift card balance has already been claimed")) ) + + val result = transactor.start(claimIfOwned = false) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `start fails with Expired when the intent is rejected as expired`() = runTest { + setupWithOwner() + setupClaimableGiftCard() coEvery { transactionController.receiveRemotely(any(), any(), any(), any()) - } returns Result.failure(alreadyClaimed) + } returns Result.failure(SubmitIntentError.StaleState(listOf("gift card is expired"))) val result = transactor.start(claimIfOwned = false) - assertTrue(result.isFailure) - assertSame(alreadyClaimed, result.exceptionOrNull()) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `start passes through other stale state rejections`() = runTest { + setupWithOwner() + setupClaimableGiftCard() + val raced = SubmitIntentError.StaleState(listOf("race detected: nonce")) + coEvery { + transactionController.receiveRemotely(any(), any(), any(), any()) + } returns Result.failure(raced) + + val result = transactor.start(claimIfOwned = false) + + assertSame(raced, result.exceptionOrNull()) } @Test