Skip to content

Commit 96817df

Browse files
committed
fix(cash): prevent duplicate gift card intent submission on share
Guard the onShared callback in shareGiftCard with an AtomicBoolean (giftCardFundingInProgress) so only the first of the two firings (shareResultReceiver + checkForShare) proceeds to fund the gift card. Add isAccountAlreadyOpened to StaleState.isExpected as a Bugsnag safety net for the "account is already opened" server error. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent a759a79 commit 96817df

4 files changed

Lines changed: 109 additions & 4 deletions

File tree

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.onEach
7474
import kotlinx.coroutines.flow.update
7575
import kotlinx.coroutines.launch
7676
import kotlinx.coroutines.suspendCancellableCoroutine
77+
import java.util.concurrent.atomic.AtomicBoolean
7778
import javax.inject.Inject
7879
import kotlin.coroutines.resume
7980
import kotlin.time.Clock
@@ -138,6 +139,7 @@ class RealSessionController @Inject constructor(
138139

139140
private val scannedRendezvous = mutableMapOf<String, Long>()
140141

142+
private val giftCardFundingInProgress = AtomicBoolean(false)
141143
private val giftCardClaimInProgress = MutableStateFlow<String?>(null)
142144

143145
init {
@@ -463,9 +465,10 @@ class RealSessionController @Inject constructor(
463465
)
464466

465467
scope.launch {
466-
shareSheetController.onShared = { result ->
468+
shareSheetController.onShared = onShared@{ result ->
467469
when (result) {
468470
is ShareResult.ActionTaken -> {
471+
if (!giftCardFundingInProgress.compareAndSet(false, true)) return@onShared
469472
scope.launch action@{
470473
// immediately fund the gift card
471474
val fundingResult = initiateGiftCardFunding(
@@ -492,7 +495,7 @@ class RealSessionController @Inject constructor(
492495
shareable = shareable,
493496
result = result
494497
)
495-
}
498+
}.invokeOnCompletion { giftCardFundingInProgress.set(false) }
496499
}
497500

498501
ShareResult.NotShared -> {

apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.flipcash.app.core.internal.bill.BillController
88
import com.flipcash.app.session.internal.toast.ToastController
99
import com.flipcash.app.featureflags.FeatureFlagController
1010
import com.flipcash.app.shareable.ShareSheetController
11+
import com.flipcash.app.shareable.ShareResult
12+
import com.flipcash.app.shareable.Shareable
1113
import com.flipcash.app.shareable.ShareableConfirmationController
1214
import com.flipcash.app.tokens.TokenCoordinator
1315
import com.flipcash.app.tokens.TokenUpdater
@@ -22,16 +24,21 @@ import com.getcode.opencode.internal.manager.VerifiedState
2224
import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError
2325
import com.getcode.opencode.model.accounts.AccountCluster
2426
import com.flipcash.app.core.bill.Bill
27+
import com.flipcash.app.core.bill.BillState
2528
import com.getcode.opencode.model.financial.LocalFiat
2629
import com.getcode.opencode.model.financial.Token
2730
import com.getcode.util.resources.ResourceHelper
2831
import com.flipcash.app.billing.BillingClient
2932
import com.flipcash.app.core.MainCoroutineRule
3033
import com.getcode.utils.network.NetworkConnectivityListener
3134
import com.getcode.util.vibration.Vibrator
35+
import com.getcode.opencode.model.accounts.GiftCardAccount
3236
import io.mockk.every
3337
import io.mockk.mockk
38+
import io.mockk.mockkObject
3439
import io.mockk.slot
40+
import io.mockk.unmockkObject
41+
import io.mockk.verify
3542
import kotlinx.coroutines.ExperimentalCoroutinesApi
3643
import kotlinx.coroutines.test.runTest
3744
import org.junit.After
@@ -80,7 +87,9 @@ class SessionControllerGiftCardErrorTest {
8087
BottomBarManager.clear()
8188
}
8289

83-
private fun createController(): RealSessionController {
90+
private fun createController(
91+
shareSheetController: ShareSheetController = mockk(relaxed = true),
92+
): RealSessionController {
8493
return RealSessionController(
8594
billController = billController,
8695
userManager = userManager,
@@ -94,7 +103,7 @@ class SessionControllerGiftCardErrorTest {
94103
tokenUpdater = mockk(relaxed = true),
95104
activityFeedUpdater = mockk(relaxed = true),
96105
profileUpdater = mockk(relaxed = true),
97-
shareSheetController = mockk(relaxed = true),
106+
shareSheetController = shareSheetController,
98107
shareConfirmationController = mockk(relaxed = true),
99108
toastController = mockk(relaxed = true),
100109
billingClient = mockk(relaxed = true),
@@ -195,6 +204,70 @@ class SessionControllerGiftCardErrorTest {
195204
assertEquals("error_title_CashReturnedToWallet", messages.first().title)
196205
}
197206

207+
@Test
208+
fun `duplicate onShared invocations only fund gift card once`() = runTest {
209+
// Mock GiftCardAccount.create to avoid MnemonicCache (requires Android context)
210+
mockkObject(GiftCardAccount.Companion)
211+
every { GiftCardAccount.create(any(), any()) } returns mockk(relaxed = true)
212+
213+
try {
214+
// Fake that fires onShared twice on present(), simulating the race between
215+
// shareResultReceiver and checkForShare().
216+
val racingShareSheet = object : ShareSheetController {
217+
override val isCheckingForShare: Boolean = false
218+
override var onShared: ((ShareResult) -> Unit)? = null
219+
override fun checkForShare() {}
220+
override suspend fun present(shareable: Shareable) {
221+
onShared?.invoke(ShareResult.SharedToApp("com.example.app"))
222+
onShared?.invoke(ShareResult.SharedToApp("com.example.app"))
223+
}
224+
override fun reset(setChecked: Boolean) {}
225+
}
226+
227+
// Capture the update lambda so we can extract the SendAsLink action
228+
val updateSlot = slot<(BillState) -> BillState>()
229+
every { billController.update(capture(updateSlot)) } answers {}
230+
231+
val controller = createController(shareSheetController = racingShareSheet)
232+
233+
val amount = mockk<LocalFiat>(relaxed = true) {
234+
every { nativeAmount.decimalValue } returns 5.0
235+
}
236+
val bill = Bill.Cash(
237+
token = mockk(relaxed = true),
238+
amount = amount,
239+
didReceive = false,
240+
kind = Bill.Kind.cash,
241+
verifiedState = mockk(relaxed = true),
242+
)
243+
244+
controller.showBill(bill)
245+
246+
// Extract and invoke the "Send as Link" action (calls shareGiftCard)
247+
val updatedState = updateSlot.captured(BillState.Default)
248+
val sendAction = updatedState.primaryAction as BillState.Action.SendAsLink
249+
sendAction.action()
250+
251+
// Wait for IO-dispatched coroutines to execute
252+
Thread.sleep(1000)
253+
254+
// The guard should ensure fundGiftCard is called exactly once, not twice
255+
verify(exactly = 1) {
256+
billController.fundGiftCard(
257+
giftCard = any(),
258+
amount = any(),
259+
token = any(),
260+
owner = any(),
261+
verifiedState = any(),
262+
onFunded = any(),
263+
onError = any(),
264+
)
265+
}
266+
} finally {
267+
unmockkObject(GiftCardAccount.Companion)
268+
}
269+
}
270+
198271
// Phase 3: fund gift card error
199272
@Test
200273
fun `fund gift card error shows failedToCreateGiftCard error`() = runTest {

services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,16 @@ sealed class SubmitIntentError(
134134
get() = reasons.any { it.contains("pool balance has already been distributed") }
135135
val isIntentAlreadyExists: Boolean
136136
get() = reasons.any { it.contains("intent already exists") }
137+
val isAccountAlreadyOpened: Boolean
138+
get() = reasons.any { it.contains("account is already opened") }
137139

138140
val isExpected: Boolean
139141
get() = isRaceCondition
140142
|| isGiftCardAlreadyClaimed
141143
|| isGiftCardExpired
142144
|| isPoolAlreadyDistributed
143145
|| isIntentAlreadyExists
146+
|| isAccountAlreadyOpened
144147

145148
override val isNotifiable: Boolean
146149
get() = !isExpected

services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,32 @@ class SubmitIntentErrorTest {
213213
assertFalse(error.isRaceCondition)
214214
}
215215

216+
@Test
217+
fun staleStateWithAccountAlreadyOpenedIsAccountAlreadyOpened() {
218+
val error = SubmitIntentError.typed(
219+
buildError(
220+
SubmitIntentResponse.Error.Code.STALE_STATE,
221+
reasonStrings = listOf("actions[0]: account is already opened")
222+
)
223+
)
224+
assertIs<SubmitIntentError.StaleState>(error)
225+
assertTrue(error.isAccountAlreadyOpened)
226+
assertTrue(error.isExpected)
227+
assertFalse(error.isNotifiable)
228+
}
229+
230+
@Test
231+
fun staleStateWithOtherReasonIsNotAccountAlreadyOpened() {
232+
val error = SubmitIntentError.typed(
233+
buildError(
234+
SubmitIntentResponse.Error.Code.STALE_STATE,
235+
reasonStrings = listOf("nonce expired")
236+
)
237+
)
238+
assertIs<SubmitIntentError.StaleState>(error)
239+
assertFalse(error.isAccountAlreadyOpened)
240+
}
241+
216242
@Test
217243
fun otherWrausesCause() {
218244
val cause = RuntimeException("root cause")

0 commit comments

Comments
 (0)