From 884dd75910f07958ab8c288402af6a0af6d048ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 12:56:11 -0400 Subject: [PATCH] fix(wallet): count all incoming money toward the "Add Money" milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding milestone only looked for a completed deposit or on-ramp buy, so a user who had been tipped — money that plainly arrived — was still shown "Add Money" as an outstanding step, and the wallet's action tiles stayed hidden. Widen the criteria to every completed *incoming* feed entry: a buy, a deposit, or a tip received. That is exactly the credit side of the feed (MessageMetadata.isOutgoing == false). Swaps stay excluded — they debit the source mint rather than bringing new money in. Renames the plumbing (hasEverAddedMoney -> hasEverReceivedMoney, State .hasAddedMoney -> .hasReceivedMoney) to match what it now measures; the TutorialItem.AddMoney label is unchanged. --- .../balance/internal/WalletScreenContent.kt | 2 +- .../app/balance/internal/WalletViewModel.kt | 12 +- .../internal/WalletLoadingStateTest.kt | 4 +- .../app/persistence/dao/MessageDao.kt | 14 +- .../app/persistence/dao/MessageDaoTest.kt | 128 ++++++++++++++++++ .../persistence/sources/MessageDataSource.kt | 7 +- .../ActivityFeedCoordinator.kt | 7 +- 7 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 8ce42fbd78..93c43719f1 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -206,7 +206,7 @@ internal fun WalletScreenContent( ) } - if (balanceState.hasAddedMoney) { + if (balanceState.hasReceivedMoney) { item { Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) } diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index cea62b350f..07bc98a76b 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -56,7 +56,7 @@ internal class WalletViewModel @Inject constructor( val transactions: List = emptyList(), val feedSyncState: FeedSyncState = FeedSyncState.Unknown, ) { - val hasAddedMoney: Boolean + val hasReceivedMoney: Boolean get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true /** Treated as complete while unknown, so the tutorial is never the thing we guess at. */ @@ -118,14 +118,14 @@ internal class WalletViewModel @Inject constructor( .launchIn(viewModelScope) // Onboarding funnel milestones, derived from durable event history (not current balance): - // "added money" = a completed deposit/buy in the activity feed; "scanned a tip card" = - // a Cash chat message with verb TIPPED. + // "added money" = any completed *incoming* entry in the activity feed — a buy, a deposit, or + // a tip received; "scanned a tip card" = an outgoing Cash chat message with verb TIPPED. combine( - feedCoordinator.hasEverAddedMoney(), + feedCoordinator.hasEverReceivedMoney(), chatCoordinator.hasEverTipped(), - ) { hasAddedMoney, hasTipped -> + ) { hasReceivedMoney, hasTipped -> listOf( - TutorialItem.AddMoney(isCompleted = hasAddedMoney), + TutorialItem.AddMoney(isCompleted = hasReceivedMoney), TutorialItem.ScanTipCard(isCompleted = hasTipped), ) } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt index 0e3b5d35d9..6668c8008c 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -97,7 +97,7 @@ class WalletLoadingStateTest { } @Test - fun `hasAddedMoney is false while unknown, gating the action tiles`() { - assertFalse(WalletViewModel.State().hasAddedMoney) + fun `hasReceivedMoney is false while unknown, gating the action tiles`() { + assertFalse(WalletViewModel.State().hasReceivedMoney) } } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt index ab975d5964..d4b6d8ecae 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt @@ -47,13 +47,21 @@ interface MessageDao { @Query("SELECT * FROM messages") suspend fun getAllMessages(): List - /** True once any completed deposit/buy notification exists — the "added money" milestone. */ + /** + * True once any completed *incoming* notification exists — the "added money" milestone. + * + * Money arriving is money arriving, regardless of how: an on-ramp buy, a deposit, or a tip + * received from someone else. All three are the credit side of the feed (see + * `MessageMetadata.isOutgoing`), so all three satisfy the milestone. Swaps are excluded — they + * debit the source mint rather than bringing new money in. + */ @Query( "SELECT EXISTS(SELECT 1 FROM messages WHERE state = 'COMPLETED' AND (" + "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.DepositedCrypto%' OR " + - "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.BoughtToken%'))" + "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.BoughtToken%' OR " + + "metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.ReceivedCrypto%'))" ) - fun hasEverAddedMoney(): Flow + fun hasEverReceivedMoney(): Flow @Query("DELETE FROM messages") suspend fun deleteAllMessages() diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt new file mode 100644 index 0000000000..cbf5604dfd --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/MessageDaoTest.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.MessageEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the "added money" onboarding milestone, which is really "has any money ever come in" — + * a buy, a deposit, or a tip received all satisfy it; outgoing entries do not. + */ +@RunWith(RobolectricTestRunner::class) +class MessageDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: MessageDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.messageDao() + } + + @After + fun tearDown() { + db.close() + } + + // -- helpers -- + + private var nextId = 0 + + private fun metadataJson(type: String) = + """{"type":"com.flipcash.app.core.feed.MessageMetadata.$type"}""" + + private fun message( + metadataType: String, + state: String = "COMPLETED", + ) = MessageEntity( + // Base58 alphabet only — the entity decodes this lazily, but keep it valid anyway. + idBase58 = "message${++nextId}", + text = "test", + amountUsdc = 100L, + amountNative = null, + nativeCurrency = null, + rate = null, + state = state, + timestamp = nextId.toLong(), + metadata = metadataJson(metadataType), + mintBase58 = null, + ) + + private suspend fun hasReceivedMoney(): Boolean { + var result = false + dao.hasEverReceivedMoney().test { + result = awaitItem() + cancelAndIgnoreRemainingEvents() + } + return result + } + + // -- tests -- + + @Test + fun `no messages means no money has come in`() = runTest { + assertFalse(hasReceivedMoney()) + } + + @Test + fun `a completed deposit satisfies the milestone`() = runTest { + dao.upsert(message("DepositedCrypto")) + assertTrue(hasReceivedMoney()) + } + + @Test + fun `a completed buy satisfies the milestone`() = runTest { + dao.upsert(message("BoughtToken")) + assertTrue(hasReceivedMoney()) + } + + @Test + fun `a received tip satisfies the milestone`() = runTest { + dao.upsert(message("ReceivedCrypto")) + assertTrue(hasReceivedMoney()) + } + + @Test + fun `outgoing activity alone does not satisfy the milestone`() = runTest { + dao.upsert( + message("DirectlySentCrypto"), + message("IndirectlySentCrypto"), + message("WithdrewCrypto"), + message("SoldToken"), + message("SwappedCrypto"), + message("PaidCrypto"), + ) + assertFalse(hasReceivedMoney()) + } + + @Test + fun `an incoming entry that has not completed does not satisfy the milestone`() = runTest { + dao.upsert(message("ReceivedCrypto", state = "PENDING")) + assertFalse(hasReceivedMoney()) + } + + @Test + fun `the milestone flips live as an incoming entry lands`() = runTest { + dao.hasEverReceivedMoney().test { + assertFalse(awaitItem()) + dao.upsert(message("ReceivedCrypto")) + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index e1c72dd556..045b6e3ae7 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -61,7 +61,8 @@ class MessageDataSource @Inject constructor( } /** - * Reactive "has the user ever added money" — any completed deposit/buy notification. + * Reactive "has money ever come in" — any completed incoming notification (buy, deposit, or a + * tip received). * * Resolved through [FlipcashDatabase.observeInstance] for the same reason as [observeRecent]: * the per-user DB is created at login, *after* singletons have built their flow graphs. Reading @@ -71,9 +72,9 @@ class MessageDataSource @Inject constructor( * activity later landed in the feed. */ @OptIn(ExperimentalCoroutinesApi::class) - fun hasEverAddedMoney(): Flow = + fun hasEverReceivedMoney(): Flow = FlipcashDatabase.observeInstance().flatMapLatest { database -> - database?.messageDao()?.hasEverAddedMoney() ?: flowOf(false) + database?.messageDao()?.hasEverReceivedMoney() ?: flowOf(false) } /** diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index 41156aa22d..228e97b931 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -263,8 +263,11 @@ class ActivityFeedCoordinator @Inject internal constructor( profiles to tokens } - /** Reactive "has the user ever added money" — any completed deposit/buy in the feed. */ - fun hasEverAddedMoney(): Flow = dataSource.hasEverAddedMoney() + /** + * Reactive "has money ever come in" — any completed incoming entry in the feed: an on-ramp buy, + * a deposit, or a tip received. + */ + fun hasEverReceivedMoney(): Flow = dataSource.hasEverReceivedMoney() suspend fun checkPendingMessagesForUpdates(): Result { val pendingMessages = dataSource.query(whereClause = "state = '${NotificationState.PENDING.name}'")