From 7211ad1e90d2e06b5bae2392a3fa46a13d0258d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 Jul 2026 12:17:56 -0400 Subject: [PATCH 1/2] feat(analytics): define unified Add Money funnel events Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/analytics/Analytics.kt | 20 ++++ .../com/flipcash/app/analytics/Events.kt | 98 +++++++++++++++++++ .../internal/MixpanelAnalyticsDelegate.kt | 39 ++++++++ 3 files changed, 157 insertions(+) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index 14dd41af8..cef001465 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -21,6 +21,14 @@ interface FlipcashAnalyticsService : AnalyticsService { fun openOnramp(source: Analytics.OnrampSource) fun onrampVerification(step: Analytics.OnrampVerificationStep) fun onrampPurchase(step: Analytics.OnrampPurchaseStep, amount: Fiat? = null) + fun addMoneyOpened(source: Analytics.AddMoneySource) + fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) + fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) + fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) + fun addMoneyAddressCopied(mint: Mint) + fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat? = null) + fun addMoneyFailed(method: Analytics.AddMoneyMethod, stage: Analytics.AddMoneyStage, error: Throwable? = null) + fun addMoneyCancelled(method: Analytics.AddMoneyMethod?, stage: Analytics.AddMoneyStage) fun connectWallet(provider: OnRampProvider.UsesDeeplinks) fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) @@ -60,6 +68,9 @@ object Analytics { data object SentCash : Transfer } enum class OnrampSource { Settings, Balance, Give } + enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall } + enum class AddMoneyMethod { Coinbase, Phantom, OtherWallet, Reserves } + enum class AddMoneyStage { MethodSelection, AmountEntry, Verification, Payment, Processing } enum class OnrampVerificationStep { ShowInfo, EnterPhone, ConfirmPhone, EnterEmail, ConfirmEmail } enum class OnrampPurchaseStep { PresetSelected, EnterCustomAmount, InvokePayment, InvokePaymentCustom, Completed } enum class TokenInfoSource { Deeplink, Wallet, Give } @@ -89,6 +100,15 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun onrampVerification(step: Analytics.OnrampVerificationStep) = Unit override fun onrampPurchase(step: Analytics.OnrampPurchaseStep, amount: Fiat?) = Unit + override fun addMoneyOpened(source: Analytics.AddMoneySource) = Unit + override fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) = Unit + override fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit + override fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit + override fun addMoneyAddressCopied(mint: Mint) = Unit + override fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat?) = Unit + override fun addMoneyFailed(method: Analytics.AddMoneyMethod, stage: Analytics.AddMoneyStage, error: Throwable?) = Unit + override fun addMoneyCancelled(method: Analytics.AddMoneyMethod?, stage: Analytics.AddMoneyStage) = Unit + override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) = Unit override fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) = Unit override fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) = Unit diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt index e7b02f98a..d1703e70c 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt @@ -242,6 +242,80 @@ internal sealed interface AnalyticsEvent { } } + sealed interface AddMoneyEvent : AnalyticsEvent { + data class Opened(val source: Analytics.AddMoneySource) : AddMoneyEvent { + override val name = "Add Money: Opened" + override fun toProperties() = mapOf("Source" to source.propertyValue) + } + + data class MethodSelected(val method: Analytics.AddMoneyMethod) : AddMoneyEvent { + override val name = "Add Money: Method Selected" + override fun toProperties() = mapOf("Method" to method.propertyValue) + } + + data class AmountConfirmed( + val method: Analytics.AddMoneyMethod, + val amount: Fiat, + ) : AddMoneyEvent { + override val name = "Add Money: Amount Confirmed" + override fun toProperties() = buildMap { + put("Method", method.propertyValue) + putAll(amount.asProperties()) + } + } + + data class PaymentInvoked( + val method: Analytics.AddMoneyMethod, + val amount: Fiat, + ) : AddMoneyEvent { + override val name = "Add Money: Payment Invoked" + override fun toProperties() = buildMap { + put("Method", method.propertyValue) + putAll(amount.asProperties()) + } + } + + data class AddressCopied(val mint: Mint) : AddMoneyEvent { + override val name = "Add Money: Address Copied" + override fun toProperties() = mapOf("Mint" to mint.base58()) + } + + data class Completed( + val method: Analytics.AddMoneyMethod, + val amount: Fiat?, + ) : AddMoneyEvent { + override val name = "Add Money: Completed" + override fun toProperties() = buildMap { + put("Method", method.propertyValue) + amount?.let { putAll(it.asProperties()) } + } + } + + data class Failed( + val method: Analytics.AddMoneyMethod, + val stage: Analytics.AddMoneyStage, + val error: Throwable?, + ) : AddMoneyEvent { + override val name = "Add Money: Failed" + override fun toProperties() = buildMap { + put("Method", method.propertyValue) + put("Stage", stage.propertyValue) + error?.let { put("Error", it.message.orEmpty()) } + } + } + + data class Cancelled( + val method: Analytics.AddMoneyMethod?, + val stage: Analytics.AddMoneyStage, + ) : AddMoneyEvent { + override val name = "Add Money: Cancelled" + override fun toProperties() = buildMap { + method?.let { put("Method", it.propertyValue) } + put("Stage", stage.propertyValue) + } + } + } + sealed interface OpenTokenInfoEvent : AnalyticsEvent { val mint: Mint override fun toProperties() = mapOf("Mint" to mint.base58()) @@ -297,6 +371,30 @@ internal sealed interface AnalyticsEvent { } } +internal val Analytics.AddMoneySource.propertyValue: String + get() = when (this) { + Analytics.AddMoneySource.Menu -> "Menu" + Analytics.AddMoneySource.GiveShortfall -> "Give Shortfall" + Analytics.AddMoneySource.BuyShortfall -> "Buy Shortfall" + } + +internal val Analytics.AddMoneyMethod.propertyValue: String + get() = when (this) { + Analytics.AddMoneyMethod.Coinbase -> "Coinbase" + Analytics.AddMoneyMethod.Phantom -> "Phantom" + Analytics.AddMoneyMethod.OtherWallet -> "Other Wallet" + Analytics.AddMoneyMethod.Reserves -> "Reserves" + } + +internal val Analytics.AddMoneyStage.propertyValue: String + get() = when (this) { + Analytics.AddMoneyStage.MethodSelection -> "Method Selection" + Analytics.AddMoneyStage.AmountEntry -> "Amount Entry" + Analytics.AddMoneyStage.Verification -> "Verification" + Analytics.AddMoneyStage.Payment -> "Payment" + Analytics.AddMoneyStage.Processing -> "Processing" + } + internal fun LocalFiat.asProperties(): Map { return buildMap { putAll(underlyingTokenAmount.asProperties()) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt index c9bd30b76..f3d139b5c 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt @@ -140,6 +140,45 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(event) } + override fun addMoneyOpened(source: Analytics.AddMoneySource) { + track(AnalyticsEvent.AddMoneyEvent.Opened(source)) + } + + override fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) { + track(AnalyticsEvent.AddMoneyEvent.MethodSelected(method)) + } + + override fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) { + track(AnalyticsEvent.AddMoneyEvent.AmountConfirmed(method, amount)) + } + + override fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) { + track(AnalyticsEvent.AddMoneyEvent.PaymentInvoked(method, amount)) + } + + override fun addMoneyAddressCopied(mint: Mint) { + track(AnalyticsEvent.AddMoneyEvent.AddressCopied(mint)) + } + + override fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat?) { + track(AnalyticsEvent.AddMoneyEvent.Completed(method, amount)) + } + + override fun addMoneyFailed( + method: Analytics.AddMoneyMethod, + stage: Analytics.AddMoneyStage, + error: Throwable? + ) { + track(AnalyticsEvent.AddMoneyEvent.Failed(method, stage, error)) + } + + override fun addMoneyCancelled( + method: Analytics.AddMoneyMethod?, + stage: Analytics.AddMoneyStage + ) { + track(AnalyticsEvent.AddMoneyEvent.Cancelled(method, stage)) + } + override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) { track(AnalyticsEvent.WalletConnect(provider)) } From 17a8563df85697033845cd5eaa5976bed8b3c1d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 Jul 2026 12:17:56 -0400 Subject: [PATCH 2/2] feat(analytics): add Add Money funnel tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a unified 'Add Money' event family, modeled after the existing transfer events (single terminal event with State/Error properties): - Add Money: Opened — Source: Menu | Give Shortfall | Buy Shortfall | Chat | Scanner | Balance - Add Money: Method Selected — Method: Coinbase | Phantom | Other Wallet | Reserves - Add Money: Amount Confirmed — Method + amount properties - Add Money: Payment Invoked — Method + amount properties - Add Money: Address Copied — Mint (Other Wallet path) - Add Money — terminal, State=Success/Failure + Error + amount Instrumented across the flow: - MenuScreenViewModel: opened from menu tile - CashScreenViewModel: opened from give-shortfall alert (USDF only) - SwapViewModel: opened from buy-shortfall alert, amount confirmed, payment invoked (Coinbase order placed / Phantom pre-sign), terminal success on balance delivery, terminal failure on order delivery failure/timeout, Coinbase web errors, order placement errors, and Phantom transaction errors - ChatViewModel: opened from no-balance prompt when starting a chat - DepositDelegate: opened from scanner Give action with nothing giveable - BalanceViewModel: opened from balance screen (AddMoneyUX only) - InternalPurchaseMethodController: method selected from deposit sheet - DepositViewModel: address copied Funnel events only fire when acquiring USDF via an external funding source; launchpad token buys remain tracked via buy()/sell(). Signed-off-by: Brandon McAnsh --- .../app/balance/internal/BalanceViewModel.kt | 4 + .../app/cash/internal/CashScreenViewModel.kt | 7 ++ .../cash/internal/CashScreenViewModelTest.kt | 2 + .../features/deposit/build.gradle.kts | 1 + .../app/deposit/internal/DepositViewModel.kt | 5 ++ .../app/menu/internal/MenuScreenViewModel.kt | 4 + .../app/messenger/internal/ChatViewModel.kt | 1 + .../com/flipcash/app/analytics/Analytics.kt | 11 +-- .../com/flipcash/app/analytics/Events.kt | 48 ++---------- .../internal/MixpanelAnalyticsDelegate.kt | 23 +++--- .../flipcash/shared/payments/build.gradle.kts | 1 + .../InternalPurchaseMethodController.kt | 15 ++++ .../internal/delegates/DepositDelegate.kt | 5 ++ .../flipcash/app/tokens/ui/SwapViewModel.kt | 74 ++++++++++++++++++- 14 files changed, 134 insertions(+), 67 deletions(-) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt index 33655744a..bcc885a99 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/BalanceViewModel.kt @@ -1,6 +1,8 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController @@ -28,6 +30,7 @@ internal class BalanceViewModel @Inject constructor( dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, featureFlags: FeatureFlagController, + analytics: FlipcashAnalyticsService, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -68,6 +71,7 @@ internal class BalanceViewModel @Inject constructor( dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery)) return@mapNotNull null } + analytics.addMoneyOpened(Analytics.AddMoneySource.Balance) purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index fe2a7bb3e..f0d29752d 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -1,6 +1,8 @@ package com.flipcash.app.cash.internal import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.tokens.SwapPurpose @@ -24,6 +26,7 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.TokenWithLocalizedBalance import com.getcode.opencode.model.financial.minus +import com.getcode.opencode.model.financial.usdf import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -55,6 +58,7 @@ internal class CashScreenViewModel @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, tokenCoordinator: TokenCoordinator, transactionController: TransactionOperations, + analytics: FlipcashAnalyticsService, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -272,6 +276,9 @@ internal class CashScreenViewModel @Inject constructor( .onEach { shortfall -> // route directly to the swap amount screen, skipping token info val mint = stateFlow.value.selectedTokenAddress!! + if (mint == Mint.usdf) { + analytics.addMoneyOpened(Analytics.AddMoneySource.GiveShortfall) + } dispatchEvent( Event.OpenScreen( AppRoute.Token.Swap( diff --git a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt index f3b2f3439..ae4e368d1 100644 --- a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt +++ b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.cash.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.app.analytics.StubFlipcashAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator @@ -78,6 +79,7 @@ class CashScreenViewModelTest { verifiedFiatCalculator = verifiedFiatCalculator, tokenCoordinator = tokenCoordinator, transactionController = transactionController, + analytics = StubFlipcashAnalytics(), dispatchers = dispatchers, ) } diff --git a/apps/flipcash/features/deposit/build.gradle.kts b/apps/flipcash/features/deposit/build.gradle.kts index d317e6443..208119edc 100644 --- a/apps/flipcash/features/deposit/build.gradle.kts +++ b/apps/flipcash/features/deposit/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":libs:messaging")) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":services:flipcash")) diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt index aa4656e8a..f30eed5a6 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.deposit.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.setText import com.flipcash.features.deposit.R @@ -34,6 +35,7 @@ internal class DepositViewModel @Inject constructor( resources: ResourceHelper, dispatchers: DispatcherProvider, featureFlags: FeatureFlagController, + analytics: FlipcashAnalyticsService, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -106,6 +108,9 @@ internal class DepositViewModel @Inject constructor( text = stateFlow.value.depositAddress, label = resources.getString(R.string.title_clipboardLabelDepositAddress), ) + stateFlow.value.selectedTokenAddress?.let { mint -> + analytics.addMoneyAddressCopied(mint) + } } .onEach { dispatchEvent(Event.SetCopied(true)) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index faf3ca8a5..75174694c 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -1,6 +1,8 @@ package com.flipcash.app.menu.internal import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo @@ -53,6 +55,7 @@ internal class MenuScreenViewModel @Inject constructor( dispatchers: DispatcherProvider, releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, + analytics: FlipcashAnalyticsService, ) : BaseViewModel( initialState = State(), @@ -165,6 +168,7 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { + analytics.addMoneyOpened(Analytics.AddMoneySource.Menu) val depositFirstUx = featureFlags.get(FeatureFlag.AddMoneyUX) if (!depositFirstUx) { return@mapNotNull AppRoute.Transfers.Deposit() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 2f0210ad3..1c9cc1c1f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -535,6 +535,7 @@ internal class ChatViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { + analytics.addMoneyOpened(Analytics.AddMoneySource.Chat) purchaseMethodController.presentDepositOptions()?.let { route -> dispatchEvent(Event.OpenScreen(route)) } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index cef001465..d5d539809 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -26,9 +26,7 @@ interface FlipcashAnalyticsService : AnalyticsService { fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) fun addMoneyAddressCopied(mint: Mint) - fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat? = null) - fun addMoneyFailed(method: Analytics.AddMoneyMethod, stage: Analytics.AddMoneyStage, error: Throwable? = null) - fun addMoneyCancelled(method: Analytics.AddMoneyMethod?, stage: Analytics.AddMoneyStage) + fun addMoney(method: Analytics.AddMoneyMethod, amount: Fiat? = null, successful: Boolean = true, error: Throwable? = null) fun connectWallet(provider: OnRampProvider.UsesDeeplinks) fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) @@ -68,9 +66,8 @@ object Analytics { data object SentCash : Transfer } enum class OnrampSource { Settings, Balance, Give } - enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall } + enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, Chat, Scanner, Balance } enum class AddMoneyMethod { Coinbase, Phantom, OtherWallet, Reserves } - enum class AddMoneyStage { MethodSelection, AmountEntry, Verification, Payment, Processing } enum class OnrampVerificationStep { ShowInfo, EnterPhone, ConfirmPhone, EnterEmail, ConfirmEmail } enum class OnrampPurchaseStep { PresetSelected, EnterCustomAmount, InvokePayment, InvokePaymentCustom, Completed } enum class TokenInfoSource { Deeplink, Wallet, Give } @@ -105,9 +102,7 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit override fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit override fun addMoneyAddressCopied(mint: Mint) = Unit - override fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat?) = Unit - override fun addMoneyFailed(method: Analytics.AddMoneyMethod, stage: Analytics.AddMoneyStage, error: Throwable?) = Unit - override fun addMoneyCancelled(method: Analytics.AddMoneyMethod?, stage: Analytics.AddMoneyStage) = Unit + override fun addMoney(method: Analytics.AddMoneyMethod, amount: Fiat?, successful: Boolean, error: Throwable?) = Unit override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) = Unit override fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) = Unit diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt index d1703e70c..7cc9c4470 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt @@ -280,39 +280,9 @@ internal sealed interface AnalyticsEvent { override fun toProperties() = mapOf("Mint" to mint.base58()) } - data class Completed( - val method: Analytics.AddMoneyMethod, - val amount: Fiat?, - ) : AddMoneyEvent { - override val name = "Add Money: Completed" - override fun toProperties() = buildMap { - put("Method", method.propertyValue) - amount?.let { putAll(it.asProperties()) } - } - } - - data class Failed( - val method: Analytics.AddMoneyMethod, - val stage: Analytics.AddMoneyStage, - val error: Throwable?, - ) : AddMoneyEvent { - override val name = "Add Money: Failed" - override fun toProperties() = buildMap { - put("Method", method.propertyValue) - put("Stage", stage.propertyValue) - error?.let { put("Error", it.message.orEmpty()) } - } - } - - data class Cancelled( - val method: Analytics.AddMoneyMethod?, - val stage: Analytics.AddMoneyStage, - ) : AddMoneyEvent { - override val name = "Add Money: Cancelled" - override fun toProperties() = buildMap { - method?.let { put("Method", it.propertyValue) } - put("Stage", stage.propertyValue) - } + data class Terminal(val method: Analytics.AddMoneyMethod) : AddMoneyEvent { + override val name = "Add Money" + override fun toProperties() = mapOf("Method" to method.propertyValue) } } @@ -376,6 +346,9 @@ internal val Analytics.AddMoneySource.propertyValue: String Analytics.AddMoneySource.Menu -> "Menu" Analytics.AddMoneySource.GiveShortfall -> "Give Shortfall" Analytics.AddMoneySource.BuyShortfall -> "Buy Shortfall" + Analytics.AddMoneySource.Chat -> "Chat" + Analytics.AddMoneySource.Scanner -> "Scanner" + Analytics.AddMoneySource.Balance -> "Balance" } internal val Analytics.AddMoneyMethod.propertyValue: String @@ -386,15 +359,6 @@ internal val Analytics.AddMoneyMethod.propertyValue: String Analytics.AddMoneyMethod.Reserves -> "Reserves" } -internal val Analytics.AddMoneyStage.propertyValue: String - get() = when (this) { - Analytics.AddMoneyStage.MethodSelection -> "Method Selection" - Analytics.AddMoneyStage.AmountEntry -> "Amount Entry" - Analytics.AddMoneyStage.Verification -> "Verification" - Analytics.AddMoneyStage.Payment -> "Payment" - Analytics.AddMoneyStage.Processing -> "Processing" - } - internal fun LocalFiat.asProperties(): Map { return buildMap { putAll(underlyingTokenAmount.asProperties()) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt index f3d139b5c..deb577524 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt @@ -160,23 +160,18 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.AddMoneyEvent.AddressCopied(mint)) } - override fun addMoneyCompleted(method: Analytics.AddMoneyMethod, amount: Fiat?) { - track(AnalyticsEvent.AddMoneyEvent.Completed(method, amount)) - } - - override fun addMoneyFailed( + override fun addMoney( method: Analytics.AddMoneyMethod, - stage: Analytics.AddMoneyStage, + amount: Fiat?, + successful: Boolean, error: Throwable? ) { - track(AnalyticsEvent.AddMoneyEvent.Failed(method, stage, error)) - } - - override fun addMoneyCancelled( - method: Analytics.AddMoneyMethod?, - stage: Analytics.AddMoneyStage - ) { - track(AnalyticsEvent.AddMoneyEvent.Cancelled(method, stage)) + track( + AnalyticsEvent.AddMoneyEvent.Terminal(method), + "State" to if (successful) "Success" else "Failure", + *amount?.asProperties()?.toList()?.toTypedArray() ?: emptyArray(), + *error.asProperty() + ) } override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) { diff --git a/apps/flipcash/shared/payments/build.gradle.kts b/apps/flipcash/shared/payments/build.gradle.kts index b201ce60b..2fb1f6948 100644 --- a/apps/flipcash/shared/payments/build.gradle.kts +++ b/apps/flipcash/shared/payments/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens:core")) implementation(project(":apps:flipcash:shared:userflags")) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt index 53789af67..8bd02c72e 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPurchaseMethodController.kt @@ -1,5 +1,7 @@ package com.flipcash.app.payments.internal +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose @@ -54,6 +56,7 @@ class InternalPurchaseMethodController @Inject constructor( exchange: Exchange, private val resources: ResourceHelper, private val userManager: UserManager, + private val analytics: FlipcashAnalyticsService, ) : PurchaseMethodController { private val scope = CoroutineScope(SupervisorJob()) @@ -148,6 +151,18 @@ class InternalPurchaseMethodController @Inject constructor( _dismissals.map { null }, ).first() + when (result) { + PurchaseMethod.CoinbaseOnRamp -> + analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Coinbase) + PurchaseMethod.PhantomWallet -> + analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Phantom) + PurchaseMethod.OtherWallet -> + analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.OtherWallet) + is PurchaseMethod.CashReserves -> + analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Reserves) + null -> Unit + } + return when (result) { PurchaseMethod.CoinbaseOnRamp -> { val profile = userManager.profile diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt index 01479b215..c7738f056 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt @@ -1,5 +1,7 @@ package com.flipcash.app.session.internal.delegates +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController @@ -38,6 +40,7 @@ class DepositDelegate @Inject constructor( private val usdcSweep: UsdcDepositSweep, private val userManager: UserManager, private val resources: ResourceHelper, + private val analytics: FlipcashAnalyticsService, dispatchers: DispatcherProvider, featureFlagController: FeatureFlagController, ) : DepositOperations { @@ -77,6 +80,8 @@ class DepositDelegate @Inject constructor( text = resources.getString(R.string.action_addMoney) ) { scope.launch { + // the scanner's Give action is the only session caller today + analytics.addMoneyOpened(Analytics.AddMoneySource.Scanner) val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) if (destination != null) { onRoute?.invoke(destination) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index d98d81b59..733ef64e1 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -320,6 +320,20 @@ class SwapViewModel @Inject constructor( data class OpenScreen(val screen: AppRoute) : Event } + // "Add Money" funnel events only apply when acquiring USDF via an external + // funding source; token buys funded from reserves or launchpad purchases are + // tracked separately via buy()/sell(). + private val addMoneyMethod: Analytics.AddMoneyMethod? + get() { + val purpose = stateFlow.value.purpose as? SwapPurpose.Buy ?: return null + if (purpose.mint != Mint.usdf) return null + return when (purpose.fundingSource) { + FundingSource.Coinbase -> Analytics.AddMoneyMethod.Coinbase + FundingSource.Phantom -> Analytics.AddMoneyMethod.Phantom + FundingSource.Flexible -> null + } + } + private val enteredAmount: Fiat get() { val delegateState = amountDelegate.state.value @@ -556,6 +570,15 @@ class SwapViewModel @Inject constructor( .onEach { (delegateState, purpose) -> val mustAddMoney = featureFlags.get(FeatureFlag.AddMoneyUX) val isAddingMoney = stateFlow.value.isAddingMoney + addMoneyMethod?.let { method -> + analytics.addMoneyAmountConfirmed( + method = method, + amount = Fiat( + delegateState.enteredAmount, + delegateState.currency.code ?: CurrencyCode.USD, + ), + ) + } when (purpose) { is SwapPurpose.Buy -> { val rate = exchange.preferredRate @@ -722,12 +745,28 @@ class SwapViewModel @Inject constructor( is OrderDeliveryResult.Delivered -> true OrderDeliveryResult.Failed -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) + addMoneyMethod?.let { method -> + analytics.addMoney( + method = method, + amount = netTransferAmount, + successful = false, + error = IllegalStateException("Order delivery failed"), + ) + } false } // The deposit may still land later; the session's foreground sweep // will reconcile it. Don't claim success we can't confirm. OrderDeliveryResult.TimedOut -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) + addMoneyMethod?.let { method -> + analytics.addMoney( + method = method, + amount = netTransferAmount, + successful = false, + error = IllegalStateException("Order delivery timed out"), + ) + } false } } @@ -743,6 +782,9 @@ class SwapViewModel @Inject constructor( tokenCoordinator.balanceForToken(Mint.usdf).first { it > baseline } feedCoordinator.fetchSinceLatest() dispatchEvent(Event.UpdateProcessingState(loading = false, success = true)) + addMoneyMethod?.let { method -> + analytics.addMoney(method, netTransferAmount) + } }.launchIn(viewModelScope) eventFlow @@ -898,7 +940,13 @@ class SwapViewModel @Inject constructor( coinbaseOnRampController.state .onEach { s -> when (s) { - is CoinbaseOnRampState.Failed, + is CoinbaseOnRampState.Failed -> { + addMoneyMethod?.let { method -> + analytics.addMoney(method, successful = false, error = s.error) + } + dispatchEvent(Event.UpdateBuyState()) + } + CoinbaseOnRampState.Idle -> dispatchEvent(Event.UpdateBuyState()) is CoinbaseOnRampState.Completed, @@ -990,6 +1038,7 @@ class SwapViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { + analytics.addMoneyOpened(Analytics.AddMoneySource.BuyShortfall) if (!featureFlags.get(FeatureFlag.AddMoneyUX)) { return@mapNotNull AppRoute.Transfers.Deposit(showOtherOptions = false) } @@ -1071,6 +1120,9 @@ class SwapViewModel @Inject constructor( verifiedFiat = amountFiat, ).onSuccess { trackTransaction(token) + addMoneyMethod?.let { method -> + analytics.addMoneyPaymentInvoked(method, amountFiat.localFiat.nativeAmount) + } }.onFailure { error -> dispatchEvent(Event.UpdateBuyState()) when (error) { @@ -1082,6 +1134,14 @@ class SwapViewModel @Inject constructor( else -> { trackTransaction(token, error = error) + addMoneyMethod?.let { method -> + analytics.addMoney( + method = method, + amount = amountFiat.localFiat.nativeAmount, + successful = false, + error = error, + ) + } BottomBarManager.showError( title = resources.getString(R.string.error_title_buySellFailed), message = resources.getString(R.string.error_description_buySellFailed), @@ -1154,6 +1214,9 @@ class SwapViewModel @Inject constructor( OnRampProvider.Phantom, amount.localFiat.underlyingTokenAmount ) + addMoneyMethod?.let { method -> + analytics.addMoneyPaymentInvoked(method, amount.localFiat.nativeAmount) + } }, ).onSuccess { result -> when (result) { @@ -1180,8 +1243,13 @@ class SwapViewModel @Inject constructor( if (deeplinkError is DeeplinkOnRampError.WalletProvidedError && deeplinkError.code == DeeplinkError.UserRejectedRequest.code) { analytics.walletTransactionCancelled(OnRampProvider.Phantom) - } else if (deeplinkError is DeeplinkOnRampError.FailedToSendTransaction) { - analytics.walletTransactionFailed(OnRampProvider.Phantom) + } else { + if (deeplinkError is DeeplinkOnRampError.FailedToSendTransaction) { + analytics.walletTransactionFailed(OnRampProvider.Phantom) + } + addMoneyMethod?.let { method -> + analytics.addMoney(method, successful = false, error = deeplinkError) + } } trace(