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 cd96fced4d..96244bbe24 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 @@ -34,6 +34,7 @@ import com.flipcash.analytics.GroupGateFunding import com.flipcash.analytics.GroupInviteMethod import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.analytics.GroupInviteSource +import com.flipcash.analytics.SendContext import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.ChatEvents import com.flipcash.analytics.events.GroupEvents @@ -3848,7 +3849,7 @@ internal class ChatViewModel @AssistedInject constructor( val sentAmount = verifiedFiat.localFiat.analytics when { isCashLink -> TransferEvents.sendCashLink(state, sentAmount, CashLinkChoice.GROUP_CHAT, null, error) - else -> TransferEvents.sentCash(state, sentAmount, error) + else -> TransferEvents.sentCash(state, sentAmount, error, SendContext.DIRECT) } } @@ -4084,7 +4085,7 @@ internal class ChatViewModel @AssistedInject constructor( holdPaymentSuccess( show = { dispatchEvent(Event.SendStateUpdated(success = true)) }, release = { - analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null)) + analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null, SendContext.GROUP_SEND, groupSend.selected.size)) // The send state stays until the sheet has gone, so Select People doesn't flash back to // the keypad on the way out. The next "$" starts a fresh one. dispatchEvent(Dispatchers.Main, Event.SendComplete(total)) @@ -4092,7 +4093,7 @@ internal class ChatViewModel @AssistedInject constructor( ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) - analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics)) + analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics, SendContext.GROUP_SEND, groupSend.selected.size)) val (title, message) = when (cause) { is ComputeVerifiedFiatError.AmountBelowMinimum -> R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt index a3068b0f04..6f7c0deb0c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/currencysend/CurrencySendViewModel.kt @@ -5,6 +5,7 @@ import com.getcode.view.PaymentSuccessHoldDuration import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.SendContext import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalytics @@ -319,7 +320,7 @@ internal class CurrencySendViewModel @Inject constructor( show = { _state.update { it.copy(sendProgress = LoadingSuccessState(success = true)) } }, release = { analytics.track( - TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null, SendContext.DIRECT) ) _done.trySend(Unit) }, @@ -331,6 +332,7 @@ internal class CurrencySendViewModel @Inject constructor( AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics, + SendContext.DIRECT, ) ) BottomBarManager.showError( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt index 7cb66bcc1e..a1c35ded4c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/payment/StartChattingPayer.kt @@ -2,6 +2,8 @@ package com.flipcash.app.messenger.internal.payment import androidx.annotation.StringRes import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.Amount +import com.flipcash.analytics.SendContext import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.analytics.events.TransferEvents @@ -187,9 +189,11 @@ internal class DefaultStartChattingPayer @Inject constructor( action = action, ) val sentAmount = verifiedFiat.localFiat.analytics - val sent = when (action) { - TipAction.TIP -> TransferEvents::sentTip - TipAction.SEND -> TransferEvents::sentCash + val sent = { state: AnalyticsState, amount: Amount, error: String? -> + when (action) { + TipAction.TIP -> TransferEvents.sentTip(state, amount, error) + TipAction.SEND -> TransferEvents.sentCash(state, amount, error, SendContext.DIRECT) + } } analytics.track( result.fold( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index 92af2607d9..6fa52225f8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -5,6 +5,10 @@ import com.getcode.view.PaymentSuccessHoldDuration import com.getcode.view.holdPaymentSuccess import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.PaymentRequestEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.internal.errors.showNetworkError import com.flipcash.app.messenger.internal.payment.StartChattingPayer @@ -69,6 +73,7 @@ internal class RequestDetailsViewModel @Inject constructor( private val exchange: Exchange, private val userManager: UserManager, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : ViewModel() { sealed interface Sheet { @@ -172,6 +177,7 @@ internal class RequestDetailsViewModel @Inject constructor( exchange = exchange, userManager = userManager, resources = resources, + analytics = analytics, openScreen = { route -> effects.trySend(Effect.OpenScreen(route)) }, onNothingToPay = { _state.update { it.copy(hasPaymentIntent = false) } }, ) @@ -194,7 +200,16 @@ internal class RequestDetailsViewModel @Inject constructor( fun close() { val id = _state.value.requestId ?: return - runCreatorAction { requests.close(id) } + runCreatorAction { + requests.close(id).also { result -> + analytics.track( + PaymentRequestEvents.closed( + if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + result.exceptionOrNull()?.analytics, + ), + ) + } + } } /** A tap on a row: only the creator, on an open request, on someone else's unpaid share. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt index 9af3ca5ce5..11e269d833 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt @@ -2,6 +2,10 @@ package com.flipcash.app.messenger.internal.request import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.PaymentRequestEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.internal.errors.showNetworkError import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.features.messenger.R @@ -65,6 +69,7 @@ internal class RequestMoneyViewModel @Inject constructor( private val crypto: GroupContentCrypto, private val chatCoordinator: ChatCoordinator, private val userManager: UserManager, + private val analytics: FlipcashAnalytics, ) : ViewModel() { enum class Stage { History, Amount, Summary, People, Name } @@ -251,10 +256,15 @@ internal class RequestMoneyViewModel @Inject constructor( viewModelScope.launch { val requestId = draft.createdRequestId ?: run { val text = crypto.sealPaymentRequestText(chatId, viewerId, draft.title, "") - .getOrElse { return@launch failed(it, chatId) } + .getOrElse { return@launch failed(it, chatId, draft) } val split = draft.toSplit(chatId, text, Clock.System.now()) - ?: return@launch failed(IllegalStateException("draft not sendable"), chatId) - requests.create(split, key).getOrElse { return@launch failed(it, chatId) }.id + ?: return@launch failed(IllegalStateException("draft not sendable"), chatId, draft) + val created = requests.create(split, key).getOrElse { return@launch failed(it, chatId, draft) } + // Once per confirm: a retry reuses createdRequestId and never reaches here. + analytics.track( + PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.perPerson?.analytics, null), + ) + created.id } _state.update { it.copy(draft = it.draft?.copy(createdRequestId = requestId)) } // The request is live, so the deck shows it whatever happens here. Only the message is @@ -283,7 +293,15 @@ internal class RequestMoneyViewModel @Inject constructor( ) } - private suspend fun failed(error: Throwable, chatId: ChatId) { + private suspend fun failed(error: Throwable, chatId: ChatId, draft: NewRequestDraft) { + analytics.track( + PaymentRequestEvents.created( + draft.requestedCount, + AnalyticsState.FAILURE, + draft.perPerson?.analytics, + error.analytics, + ), + ) _state.update { it.copy(sheet = Sheet.None, sendProgress = LoadingSuccessState()) } val message = when (error) { is CreatePaymentRequestError.Denied -> R.string.error_description_requestDenied diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt index 1c3c05c481..dd9c3433ff 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlow.kt @@ -1,5 +1,10 @@ package com.flipcash.app.messenger.internal.request +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.AppRoute import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.messenger.internal.swipepay.SwipePayActions @@ -46,6 +51,7 @@ internal class RequestPayFlow( private val exchange: Exchange, private val userManager: UserManager, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, /** Where "Add Money" goes, once the sheet has closed. */ private val openScreen: (AppRoute) -> Unit, /** The server has no payment intent for the viewer on this request any more. */ @@ -118,7 +124,10 @@ internal class RequestPayFlow( .onSuccess { holdPaymentSuccess( show = { _state.update { it.copy(progress = LoadingSuccessState(success = true)) } }, - release = { dismiss() }, + release = { + analytics.track(sentCash(AnalyticsState.SUCCESS, pending, null)) + dismiss() + }, ) } .onFailure { cause -> afterFailedPayment(pending, cause) } @@ -130,6 +139,7 @@ internal class RequestPayFlow( * paid, and a request closed meanwhile is "Request Closed". */ private suspend fun afterFailedPayment(pending: ShareToPay.Ready, cause: Throwable) { + analytics.track(sentCash(AnalyticsState.FAILURE, pending, cause.analytics)) _state.update { it.copy(progress = LoadingSuccessState()) } val viewer = viewerId ?: return when (val found = payments.prepare(pending.request.id, viewer).getOrNull()) { @@ -146,6 +156,9 @@ internal class RequestPayFlow( } } + private fun sentCash(state: AnalyticsState, pending: ShareToPay.Ready, error: String?) = + TransferEvents.sentCash(state, pending.share.amount.analytics, error, SendContext.PAYMENT_REQUEST) + fun dismiss() { if (!_state.value.progress.isIdle && !_state.value.progress.success) return ready = null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt index 98cfbcb0f8..4bbc26b0a7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestPayViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import com.flipcash.app.analytics.FlipcashAnalytics import javax.inject.Inject /** @@ -42,6 +43,7 @@ internal class RequestPayViewModel @Inject constructor( exchange: Exchange, userManager: UserManager, resources: ResourceHelper, + analytics: FlipcashAnalytics, ) : ViewModel() { private val effects = Channel(Channel.BUFFERED) @@ -58,6 +60,7 @@ internal class RequestPayViewModel @Inject constructor( exchange = exchange, userManager = userManager, resources = resources, + analytics = analytics, openScreen = { route -> effects.trySend(route) }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostViewModel.kt index d65068030e..2f432e9bc1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostViewModel.kt @@ -1,6 +1,11 @@ package com.flipcash.app.messenger.internal.screens.boost import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.ChatType as AnalyticsChatType +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.messenger.internal.payment.ReactionBoostPayer import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.libs.coroutines.DispatcherProvider @@ -11,6 +16,7 @@ import com.flipcash.shared.chat.MessageReactions import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.payments.boost.BoostChat import com.flipcash.shared.payments.boost.ReactionBoostPlan +import com.flipcash.shared.payments.boost.centsToUsd import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token import com.getcode.utils.TraceType @@ -51,6 +57,7 @@ internal class ReactionBoostViewModel @Inject constructor( private val payer: ReactionBoostPayer, private val splitResolver: ReactionBoostSplitResolver, startChattingPayer: StartChattingPayer, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -213,15 +220,32 @@ internal class ReactionBoostViewModel @Inject constructor( totalCents = cents, onAddMoney = { dispatchEvent(Event.AddMoney) }, ).onSuccess { + trackBoost(metadata?.type, cents, AnalyticsState.SUCCESS, null) dispatchEvent(Event.SendProgressChanged(LoadingSuccessState(success = true))) delay(SUCCESS_HOLD_MS) dispatchEvent(Event.Completed) - }.onFailure { + }.onFailure { cause -> + // A PaymentBlocked is a pre-flight stop (minimum, balance, limit, no account) that the + // user was already told about; no payment was attempted. + if (cause !is StartChattingPayer.PaymentBlocked) { + trackBoost(metadata?.type, cents, AnalyticsState.FAILURE, cause.analytics) + } dispatchEvent(Event.SendProgressChanged(LoadingSuccessState())) } } } + private fun trackBoost(type: ChatType?, totalCents: Long, state: AnalyticsState, error: String?) { + analytics.track( + TransferEvents.sentBoost( + type?.analytics ?: AnalyticsChatType.UNKNOWN, + state, + centsToUsd(totalCents).analytics, + error, + ), + ) + } + private fun notSent(reason: String) { trace(tag = TAG, message = "Boost not sent: $reason", type = TraceType.Error) payer.alertPaymentFailed() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsAnalyticsTest.kt new file mode 100644 index 0000000000..9281ba8b66 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsAnalyticsTest.kt @@ -0,0 +1,101 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.PaymentRequestEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.PaymentRequests +import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** `Payment Request: Closed`: on the close result, not on the confirm sheet, and not for mark-paid. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestDetailsAnalyticsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val analytics = RecordingAnalytics() + private val requests = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val rosters = mockk(relaxed = true) + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountId } returns listOf(1) + every { rosters.state } returns MutableStateFlow(emptyMap()) + every { payer.observeSelectedToken() } returns flowOf(mockk(relaxed = true)) + every { transactionController.limits } returns MutableStateFlow(null) + } + + @After + fun tearDown() = BottomBarManager.clear() + + private fun viewModel() = RequestDetailsViewModel( + requests = requests, + rosters = rosters, + payments = mockk(relaxed = true), + payer = payer, + tokenCoordinator = mockk(relaxed = true), + transactionController = transactionController, + exchange = mockk(relaxed = true), + userManager = userManager, + resources = mockk(relaxed = true), + analytics = analytics, + ).also { it.open(ChatId(ByteArray(1)), "2") } + + @Test + fun `closing a request sends Closed as a success`() { + coEvery { requests.close(any()) } returns Result.success(mockk(relaxed = true)) + val vm = viewModel() + + vm.askClose() + assertEquals(emptyList(), analytics.events) + vm.close() + + assertEquals(listOf(PaymentRequestEvents.closed(AnalyticsState.SUCCESS, null)), analytics.events) + } + + @Test + fun `a failed close sends Closed as a failure with the error`() { + coEvery { requests.close(any()) } returns Result.failure(IllegalStateException("gone")) + val vm = viewModel() + + vm.close() + + assertEquals(listOf(PaymentRequestEvents.closed(AnalyticsState.FAILURE, "gone")), analytics.events) + } + + @Test + fun `marking a share paid sends nothing`() { + coEvery { requests.markPaid(any(), any()) } returns Result.success(mockk(relaxed = true)) + val vm = viewModel() + + vm.markPaid(listOf(3)) + + assertEquals(emptyList(), analytics.events) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt index c2fb9c337a..0a2003ec59 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsInsufficientFundsTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.request +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.tokens.TokenCoordinator @@ -103,6 +104,7 @@ class RequestDetailsInsufficientFundsTest { exchange = exchange, userManager = userManager, resources = resources, + analytics = RecordingAnalytics(), ).also { it.open(ChatId(ByteArray(1)), "2") } @Test diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt new file mode 100644 index 0000000000..cf7b864229 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt @@ -0,0 +1,123 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.PaymentRequestEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.PaymentRequests +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Currency +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** `Payment Request: Created`: once per confirm, on the create result, creator not counted. */ +@OptIn(ExperimentalCoroutinesApi::class) +class RequestMoneyAnalyticsTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val viewer = listOf(1) + private val chatId = ChatId(ByteArray(1)) + private val analytics = RecordingAnalytics() + private val requests = mockk(relaxed = true) + private val crypto = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val rosters = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + + private fun member(n: Int) = mockk(relaxed = true) { every { userId } returns listOf(n.toByte()) } + + @Before + fun setUp() { + every { userManager.accountId } returns viewer + every { exchange.preferredRate } returns Rate.oneToOne + every { exchange.observePreferredRate() } returns emptyFlow() + every { exchange.getCurrency(any()) } returns Currency(code = "USD", name = "US Dollar", symbol = "$") + every { resources.getString(any()) } returns "text" + every { resources.getString(any(), *anyVararg()) } returns "text" + every { rosters.state } returns MutableStateFlow(mapOf(chatId to listOf(member(1), member(2), member(3)))) + coEvery { requests.resolvedPage(any(), any()) } returns Result.failure(IllegalStateException("no history")) + coEvery { crypto.sealPaymentRequestText(any(), any(), any(), any()) } returns Result.success(mockk(relaxed = true)) + } + + private fun viewModel() = RequestMoneyViewModel( + exchange = exchange, + resources = resources, + requests = requests, + rosters = rosters, + crypto = crypto, + chatCoordinator = chatCoordinator, + userManager = userManager, + analytics = analytics, + ).also { vm -> + vm.open(chatId) + vm.newRequest() + vm.amountDelegate.onNumber(5) + vm.amountEntered() + vm.requestConfirm() + } + + private val perPerson = Fiat(5.0, CurrencyCode.USD) + + @Test + fun `a created request sends Created once, counting the people asked and not the creator`() { + coEvery { requests.create(any(), any()) } returns Result.success(mockk(relaxed = true)) + val vm = viewModel() + + vm.send() + + assertEquals( + listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, perPerson.analytics, null)), + analytics.events, + ) + } + + @Test + fun `a rejected create sends Created as a failure with the error`() { + coEvery { requests.create(any(), any()) } returns Result.failure(IllegalStateException("denied")) + val vm = viewModel() + + vm.send() + + assertEquals( + listOf(PaymentRequestEvents.created(2, AnalyticsState.FAILURE, perPerson.analytics, "denied")), + analytics.events, + ) + } + + @Test + fun `retrying after the message failed does not send Created again`() { + coEvery { requests.create(any(), any()) } returns Result.success(mockk(relaxed = true)) + val vm = viewModel() + + vm.send() + vm.send() + + assertEquals(1, analytics.events.size) + coVerify(exactly = 1) { requests.create(any(), any()) } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt index 06709d6845..3be5b1d45d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestPayFlowTest.kt @@ -1,5 +1,10 @@ package com.flipcash.app.messenger.internal.request +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.app.tokens.TokenCoordinator @@ -53,6 +58,7 @@ class RequestPayFlowTest { private val token = mockk(relaxed = true) private val owner = mockk(relaxed = true) private val scope = CoroutineScope(Dispatchers.Unconfined) + private val analytics = RecordingAnalytics() private var balance = Fiat(100.0) private var nothingToPay = 0 @@ -90,6 +96,7 @@ class RequestPayFlowTest { exchange = exchange, userManager = userManager, resources = resources, + analytics = analytics, openScreen = {}, onNothingToPay = { nothingToPay++ }, ).also { f -> scope.launch { f.token.collect {} } } @@ -118,6 +125,39 @@ class RequestPayFlowTest { coVerify(exactly = 0) { payments.pay(any(), any(), any()) } } + @Test + fun `a failed payment sends Sent Cash with the Payment Request context`() { + coEvery { payments.pay(any(), any(), any()) } returns Result.failure(IllegalStateException("boom")) + coEvery { payments.prepare(any(), any()) } returns Result.success(ready) + val f = flow() + f.start(listOf(2)) + + f.confirm() + + assertEquals( + listOf( + TransferEvents.sentCash( + AnalyticsState.FAILURE, + Fiat(50.0).analytics, + "boom", + SendContext.PAYMENT_REQUEST, + ), + ), + analytics.events, + ) + } + + @Test + fun `a payment held back by the balance check sends nothing`() { + val f = flow() + f.start(listOf(2)) + balance = Fiat(10.0) + + f.confirm() + + assertEquals(emptyList(), analytics.events) + } + @Test fun `a closed request presents the closed alert and never opens the sheet`() { coEvery { payments.prepare(any(), any()) } returns Result.success(ShareToPay.Closed(mockk(relaxed = true))) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostAnalyticsTest.kt new file mode 100644 index 0000000000..469cc72651 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostAnalyticsTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.messenger.internal.screens.boost + +import com.flipcash.analytics.ChatType as AnalyticsChatType +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.ReactionBoostPayer +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.reactions.SelfReaction +import com.flipcash.shared.payments.boost.centsToUsd +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** `Sent Boost`: after the payment result, and only when a payment was attempted. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReactionBoostAnalyticsTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(dispatcher) + + private val chatId = ChatId(ByteArray(1)) + private val analytics = RecordingAnalytics() + private val chatCoordinator = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val startChattingPayer = mockk(relaxed = true) + + @Before + fun setUp() { + every { startChattingPayer.observeSelectedToken() } returns flowOf(mockk(relaxed = true)) + every { chatCoordinator.observeChatReactions(any()) } returns + flowOf(mapOf(7L to MessageReactions(emptyList(), listOf(SelfReaction("🔥", Instant.fromEpochSeconds(1)))))) + coEvery { chatCoordinator.getMessage(any(), any()) } returns mockk(relaxed = true) { + every { senderId } returns listOf(9) + every { reactions } returns null + } + val metadata = mockk(relaxed = true) { + every { metadata } returns mockk(relaxed = true) { + every { type } returns ChatType.CONTACT_DM + every { creator } returns null + } + } + every { chatCoordinator.observeMetadata(any()) } returns flowOf(metadata) + } + + private fun confirmedBoost(): ReactionBoostViewModel = + ReactionBoostViewModel( + chatCoordinator = chatCoordinator, + payer = payer, + splitResolver = mockk(relaxed = true), + startChattingPayer = startChattingPayer, + analytics = analytics, + dispatchers = TestDispatcherProvider(dispatcher), + ).also { vm -> + vm.dispatchEvent(ReactionBoostViewModel.Event.Open(chatId, 7)) + vm.dispatchEvent(ReactionBoostViewModel.Event.SelectAmount(500)) + vm.dispatchEvent(ReactionBoostViewModel.Event.Confirm) + } + + private val whole = centsToUsd(500).analytics + + @Test + fun `a boost that is paid sends Sent Boost as a success with the whole amount`() { + coEvery { payer.boost(any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) + + confirmedBoost() + + assertEquals( + listOf(TransferEvents.sentBoost(AnalyticsChatType.CONTACT, AnalyticsState.SUCCESS, whole, null)), + analytics.events, + ) + } + + @Test + fun `a payment that fails sends Sent Boost as a failure with the error`() { + coEvery { payer.boost(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("declined")) + + confirmedBoost() + + assertEquals( + listOf(TransferEvents.sentBoost(AnalyticsChatType.CONTACT, AnalyticsState.FAILURE, whole, "declined")), + analytics.events, + ) + } + + @Test + fun `a boost stopped before payment sends nothing`() { + coEvery { payer.boost(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(StartChattingPayer.PaymentBlocked()) + + confirmedBoost() + + assertEquals(emptyList(), analytics.events) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index 5283dbed71..e0399f5028 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -19,6 +19,7 @@ public typealias PurchaseMethod = SharedCore.PurchaseMethod public typealias LaunchMethod = SharedCore.LaunchMethod public typealias WalletProvider = SharedCore.WalletProvider public typealias CashLinkChoice = SharedCore.CashLinkChoice +public typealias SendContext = SharedCore.SendContext public typealias OnrampStep = SharedCore.OnrampStep public typealias MuteDuration = SharedCore.MuteDuration public typealias SilenceDuration = SharedCore.SilenceDuration @@ -57,6 +58,7 @@ public typealias ButtonEvents = SharedCore.ButtonEvents public typealias GroupEvents = SharedCore.GroupEvents public typealias ProfileEvents = SharedCore.ProfileEvents public typealias SearchEvents = SharedCore.SearchEvents +public typealias PaymentRequestEvents = SharedCore.PaymentRequestEvents /// A property value as the iOS sender needs it. Mixpanel is not a dependency of this /// package, so the app maps these to `MixpanelType` itself. diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index f848f29b64..45a75af7a7 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -301,11 +301,15 @@ No properties. ### Sent Cash +Cash was sent to a person or to several people, or a share of a payment request was paid. `Context` says which: Direct for a send to one person, Group Send for a send to several members of a group, Payment Request for paying a share. `Recipient Count` is the number of members paid and is sent for a Group Send only. + | Property | Type | Optional | Values | |---|---|---|---| | State | list | no | [State](#state) | | amount | group | yes | [amount](#amount) | | Error | text | yes | | +| Context | list | yes | [SendContext](#sendcontext) | +| Recipient Count | count | yes | | > **Drift:** iOS sends Exchange Rate and no USDC (see Amount). @@ -319,6 +323,41 @@ No properties. > **Drift:** iOS sends Exchange Rate and no USDC (see Amount). +### Sent Boost + +A paid reaction (boost) was confirmed, on success or failure. The amount is the whole boost, before it is split between the message's author and the group's creator. `Chat Type` is the chat the message is in. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +## Payment Request + +Group payment requests: one amount asked of several members of a group chat. Paying a share is Sent Cash with `Context` set to Payment Request. + +### Payment Request: Created + +A group payment request was created, on success or failure. The amount is what each person is asked for. `Payer Count` is the number of people asked, not counting the creator. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Payer Count | count | no | | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +### Payment Request: Closed + +The creator closed a payment request before its deadline, on success or failure. A request that reaches its deadline on its own doesn't fire this. + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| Error | text | yes | | + ## Add Money ### Add Money: Opened @@ -753,6 +792,16 @@ The `Cash Link Choice` property on Send Cash Link: how the user passed the link | Shared to app | `SHARED` | | | Posted to group chat | `GROUP_CHAT` | | +### SendContext + +The `Context` property on Sent Cash: which send the payment came from. + +| Value sent | Name in code | Drift | +|---|---|---| +| Direct | `DIRECT` | | +| Group Send | `GROUP_SEND` | | +| Payment Request | `PAYMENT_REQUEST` | | + ### AddMoneySource The `Source` property on Add Money: Opened: where the user opened Add Money from. diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 5c32db30a7..223e9d0c66 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -80,6 +80,13 @@ COPIED = "Copied to clipboard" SHARED = "Shared to app" GROUP_CHAT = "Posted to group chat" +[lists.SendContext] +description = "The `Context` property on Sent Cash: which send the payment came from." +[lists.SendContext.values] +DIRECT = "Direct" +GROUP_SEND = "Group Send" +PAYMENT_REQUEST = "Payment Request" + [lists.AddMoneySource] description = "The `Source` property on Add Money: Opened: where the user opened Add Money from." [lists.AddMoneySource.values] @@ -308,6 +315,7 @@ Search = "The Chats search screen." Swap = "Buying and selling a token." Scan = "The gallery scan path, and the tip card." Group = "Group chats. `Member Count` is the member count before the action, from what the screen already holds. `Gate Mint` is left out when the group has no token gate. `Error` is the RPC's result name as the proto spells it, such as RulesNotSatisfied, or Network for a transport failure." +"Payment Request" = "Group payment requests: one amount asked of several members of a group chat. Paying a share is Sent Cash with `Context` set to Payment Request." Deeplink = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." # --------------------------------------------------------------------------------------- @@ -582,7 +590,12 @@ drift = "iOS sends Exchange Rate and no USDC (see Amount)." [[event]] domain = "Transfer" name = "Sent Cash" -properties = [{ group = "outcome" }] +properties = [ + { group = "outcome" }, + { name = "Context", type = "SendContext", optional = true }, + { name = "Recipient Count", type = "count", optional = true }, +] +description = "Cash was sent to a person or to several people, or a share of a payment request was paid. `Context` says which: Direct for a send to one person, Group Send for a send to several members of a group, Payment Request for paying a share. `Recipient Count` is the number of members paid and is sent for a Group Send only." drift = "iOS sends Exchange Rate and no USDC (see Amount)." [[event]] @@ -591,6 +604,35 @@ name = "Sent Tip" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." +[[event]] +domain = "Transfer" +name = "Sent Boost" +properties = [ + { name = "Chat Type", type = "ChatType" }, + { group = "outcome" }, +] +description = "A paid reaction (boost) was confirmed, on success or failure. The amount is the whole boost, before it is split between the message's author and the group's creator. `Chat Type` is the chat the message is in." + +# Payment requests + +[[event]] +domain = "Payment Request" +name = "Payment Request: Created" +properties = [ + { name = "Payer Count", type = "count" }, + { group = "outcome" }, +] +description = "A group payment request was created, on success or failure. The amount is what each person is asked for. `Payer Count` is the number of people asked, not counting the creator." + +[[event]] +domain = "Payment Request" +name = "Payment Request: Closed" +properties = [ + { name = "State", type = "State" }, + { name = "Error", type = "text", optional = true }, +] +description = "The creator closed a payment request before its deadline, on success or failure. A request that reaches its deadline on its own doesn't fire this." + # Add money [[event]] diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/PaymentRequestEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/PaymentRequestEventsTest.kt new file mode 100644 index 0000000000..657d773e4b --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/PaymentRequestEventsTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.PaymentRequestEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class PaymentRequestEventsTest { + + private val amount = Amount(fiat = 5.0, currency = "USD", usdc = 5.0, quarks = 500, exchangeRate = 1.0, mint = "mint") + + @Test + fun createdCarriesPayerCountAndOutcome() { + assertEquals( + AnalyticsEvent( + "Payment Request: Created", + mapOf( + "Payer Count" to PropertyValue.Number(4.0), + "State" to PropertyValue.Text("Success"), + "Fiat" to PropertyValue.Number(5.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(5.0), + "Quarks" to PropertyValue.Number(500.0), + "Exchange Rate" to PropertyValue.Number(1.0), + "Mint" to PropertyValue.Text("mint"), + ), + ), + PaymentRequestEvents.created(4, State.SUCCESS, amount, null), + ) + } + + @Test + fun createdFailureCarriesTheError() { + assertEquals( + AnalyticsEvent( + "Payment Request: Created", + mapOf( + "Payer Count" to PropertyValue.Number(2.0), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("Denied"), + ), + ), + PaymentRequestEvents.created(2, State.FAILURE, null, "Denied"), + ) + } + + @Test + fun closedCarriesStateAndError() { + assertEquals( + AnalyticsEvent("Payment Request: Closed", mapOf("State" to PropertyValue.Text("Success"))), + PaymentRequestEvents.closed(State.SUCCESS, null), + ) + assertEquals( + AnalyticsEvent( + "Payment Request: Closed", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("Network")), + ), + PaymentRequestEvents.closed(State.FAILURE, "Network"), + ) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt index 54eb903d96..89e7277dbf 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt @@ -98,4 +98,63 @@ class TransferEventsTest { TransferEvents.sendCashLink(State.FAILURE, null, CashLinkChoice.GROUP_CHAT, null, "offline"), ) } + + @Test + fun sentCashLeavesOutContextAndRecipientCountWhenAbsent() { + assertEquals( + AnalyticsEvent("Sent Cash", mapOf("State" to PropertyValue.Text("Success")) + amountProperties), + TransferEvents.sentCash(State.SUCCESS, amount, null), + ) + } + + @Test + fun sentCashToAGroupCarriesContextAndRecipientCount() { + assertEquals( + AnalyticsEvent( + "Sent Cash", + mapOf( + "State" to PropertyValue.Text("Success"), + "Context" to PropertyValue.Text("Group Send"), + "Recipient Count" to PropertyValue.Number(3.0), + ) + amountProperties, + ), + TransferEvents.sentCash(State.SUCCESS, amount, null, SendContext.GROUP_SEND, 3), + ) + } + + @Test + fun sentCashContextValuesAreSentAsText() { + fun context(value: SendContext) = + TransferEvents.sentCash(State.SUCCESS, null, null, value).properties["Context"] + + assertEquals(PropertyValue.Text("Direct"), context(SendContext.DIRECT)) + assertEquals(PropertyValue.Text("Group Send"), context(SendContext.GROUP_SEND)) + assertEquals(PropertyValue.Text("Payment Request"), context(SendContext.PAYMENT_REQUEST)) + } + + @Test + fun sentBoostCarriesChatTypeAndOutcome() { + assertEquals( + AnalyticsEvent( + "Sent Boost", + mapOf("Chat Type" to PropertyValue.Text("Group"), "State" to PropertyValue.Text("Success")) + amountProperties, + ), + TransferEvents.sentBoost(ChatType.GROUP, State.SUCCESS, amount, null), + ) + } + + @Test + fun sentBoostFailureCarriesTheError() { + assertEquals( + AnalyticsEvent( + "Sent Boost", + mapOf( + "Chat Type" to PropertyValue.Text("Contact"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("offline"), + ), + ), + TransferEvents.sentBoost(ChatType.CONTACT, State.FAILURE, null, "offline"), + ) + } }