From fadec5add51b20f7963ad2d531bf20a55ac4e3d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:42:23 -0400 Subject: [PATCH 1/3] feat(analytics): catalogue boosts, payment requests and Sent Cash context --- .../app/messenger/internal/ChatViewModel.kt | 6 +- .../currencysend/CurrencySendViewModel.kt | 4 +- .../internal/payment/StartChattingPayer.kt | 9 ++- .../SharedCoreKit/AnalyticsEvents.swift | 2 + libs/analytics-events/EVENTS.md | 49 +++++++++++++++ libs/analytics-events/events.toml | 44 ++++++++++++- .../analytics/PaymentRequestEventsTest.kt | 60 ++++++++++++++++++ .../flipcash/analytics/TransferEventsTest.kt | 61 ++++++++++++++++++- 8 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/PaymentRequestEventsTest.kt 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..d031657a9b 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 @@ -3848,7 +3848,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, null, null) } } @@ -4084,7 +4084,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, null, null)) // 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 +4092,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, null, null)) 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..bc09bef211 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 @@ -319,7 +319,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, null, null) ) _done.trySend(Unit) }, @@ -331,6 +331,8 @@ internal class CurrencySendViewModel @Inject constructor( AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics, + null, + null, ) ) 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..f286e46758 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,7 @@ 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.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.analytics.events.TransferEvents @@ -187,9 +188,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, null, null) + } } analytics.track( result.fold( 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..1b2c589e1e 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 @@ -51,7 +51,7 @@ class TransferEventsTest { assertEquals(AnalyticsEvent("Give Bill", expected), TransferEvents.giveBill(State.FAILURE, amount, "e")) assertEquals(AnalyticsEvent("Withdrawal", expected), TransferEvents.withdrawal(State.FAILURE, amount, "e")) assertEquals(AnalyticsEvent("Receive Cash Link", expected), TransferEvents.receiveCashLink(State.FAILURE, amount, "e")) - assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e", null, null)) assertEquals(AnalyticsEvent("Sent Tip", expected), TransferEvents.sentTip(State.FAILURE, amount, "e")) } @@ -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, null, 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, null).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"), + ) + } } From 5c94763e6bf3b06459e1dcb1d10ad2114711b020 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 22:25:15 -0400 Subject: [PATCH 2/3] feat(analytics): send the money-flow events Sent Cash now carries Context at every call site (Direct, Group Send with Recipient Count, Payment Request), and paying a request share sends it for the first time on Android. Sent Boost, Payment Request: Created and Payment Request: Closed are sent on the result of the call they describe. --- .../app/messenger/internal/ChatViewModel.kt | 7 +- .../currencysend/CurrencySendViewModel.kt | 5 +- .../internal/payment/StartChattingPayer.kt | 3 +- .../request/RequestDetailsViewModel.kt | 17 ++- .../internal/request/RequestMoneyViewModel.kt | 26 +++- .../internal/request/RequestPayFlow.kt | 15 ++- .../internal/request/RequestPayViewModel.kt | 3 + .../screens/boost/ReactionBoostViewModel.kt | 26 +++- .../request/RequestDetailsAnalyticsTest.kt | 101 ++++++++++++++ .../RequestDetailsInsufficientFundsTest.kt | 2 + .../request/RequestMoneyAnalyticsTest.kt | 123 ++++++++++++++++++ .../internal/request/RequestPayFlowTest.kt | 41 ++++++ .../boost/ReactionBoostAnalyticsTest.kt | 114 ++++++++++++++++ 13 files changed, 470 insertions(+), 13 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsAnalyticsTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/boost/ReactionBoostAnalyticsTest.kt 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 d031657a9b..8dc58e99bd 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, null, null) + else -> TransferEvents.sentCash(state, sentAmount, error, SendContext.DIRECT, null) } } @@ -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, null, 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, null, null)) + 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 bc09bef211..063d0fb397 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, null, null) + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null, SendContext.DIRECT, null) ) _done.trySend(Unit) }, @@ -331,7 +332,7 @@ internal class CurrencySendViewModel @Inject constructor( AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics, - null, + SendContext.DIRECT, null, ) ) 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 f286e46758..7ca176cd9f 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 @@ -3,6 +3,7 @@ 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 @@ -191,7 +192,7 @@ internal class DefaultStartChattingPayer @Inject constructor( val sent = { state: AnalyticsState, amount: Amount, error: String? -> when (action) { TipAction.TIP -> TransferEvents.sentTip(state, amount, error) - TipAction.SEND -> TransferEvents.sentCash(state, amount, error, null, null) + TipAction.SEND -> TransferEvents.sentCash(state, amount, error, SendContext.DIRECT, null) } } analytics.track( 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..7bc988353e 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, null) + 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..0f7ba3f89d --- /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(), any()) } 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..3ae560f2f6 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,40 @@ 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, + null, + ), + ), + 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) + } +} From 01f0105f5df50511715812290f59a9c789364974 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 00:49:13 -0400 Subject: [PATCH 3/3] refactor(analytics): leave out the Sent Cash recipient count where it is absent Optional builder params now default to null, so the call sites and tests no longer pass null for Recipient Count. Also disambiguate the ResourceHelper.getString mocks in RequestMoneyAnalyticsTest. --- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 2 +- .../internal/currencysend/CurrencySendViewModel.kt | 3 +-- .../app/messenger/internal/payment/StartChattingPayer.kt | 2 +- .../app/messenger/internal/request/RequestPayFlow.kt | 2 +- .../messenger/internal/request/RequestMoneyAnalyticsTest.kt | 4 ++-- .../app/messenger/internal/request/RequestPayFlowTest.kt | 1 - .../kotlin/com/flipcash/analytics/TransferEventsTest.kt | 6 +++--- 7 files changed, 9 insertions(+), 11 deletions(-) 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 8dc58e99bd..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 @@ -3849,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, SendContext.DIRECT, null) + else -> TransferEvents.sentCash(state, sentAmount, error, SendContext.DIRECT) } } 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 063d0fb397..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 @@ -320,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, SendContext.DIRECT, null) + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null, SendContext.DIRECT) ) _done.trySend(Unit) }, @@ -333,7 +333,6 @@ internal class CurrencySendViewModel @Inject constructor( verifiedFiat.localFiat.analytics, cause.analytics, SendContext.DIRECT, - null, ) ) 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 7ca176cd9f..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 @@ -192,7 +192,7 @@ internal class DefaultStartChattingPayer @Inject constructor( 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, null) + TipAction.SEND -> TransferEvents.sentCash(state, amount, error, SendContext.DIRECT) } } analytics.track( 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 7bc988353e..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 @@ -157,7 +157,7 @@ internal class RequestPayFlow( } private fun sentCash(state: AnalyticsState, pending: ShareToPay.Ready, error: String?) = - TransferEvents.sentCash(state, pending.share.amount.analytics, error, SendContext.PAYMENT_REQUEST, null) + TransferEvents.sentCash(state, pending.share.amount.analytics, error, SendContext.PAYMENT_REQUEST) fun dismiss() { if (!_state.value.progress.isIdle && !_state.value.progress.success) return 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 index 0f7ba3f89d..cf7b864229 100644 --- 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 @@ -57,8 +57,8 @@ class RequestMoneyAnalyticsTest { 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(), any()) } returns "text" + 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)) 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 3ae560f2f6..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 @@ -141,7 +141,6 @@ class RequestPayFlowTest { Fiat(50.0).analytics, "boom", SendContext.PAYMENT_REQUEST, - null, ), ), analytics.events, 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 1b2c589e1e..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 @@ -51,7 +51,7 @@ class TransferEventsTest { assertEquals(AnalyticsEvent("Give Bill", expected), TransferEvents.giveBill(State.FAILURE, amount, "e")) assertEquals(AnalyticsEvent("Withdrawal", expected), TransferEvents.withdrawal(State.FAILURE, amount, "e")) assertEquals(AnalyticsEvent("Receive Cash Link", expected), TransferEvents.receiveCashLink(State.FAILURE, amount, "e")) - assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e", null, null)) + assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e")) assertEquals(AnalyticsEvent("Sent Tip", expected), TransferEvents.sentTip(State.FAILURE, amount, "e")) } @@ -103,7 +103,7 @@ class TransferEventsTest { fun sentCashLeavesOutContextAndRecipientCountWhenAbsent() { assertEquals( AnalyticsEvent("Sent Cash", mapOf("State" to PropertyValue.Text("Success")) + amountProperties), - TransferEvents.sentCash(State.SUCCESS, amount, null, null, null), + TransferEvents.sentCash(State.SUCCESS, amount, null), ) } @@ -125,7 +125,7 @@ class TransferEventsTest { @Test fun sentCashContextValuesAreSentAsText() { fun context(value: SendContext) = - TransferEvents.sentCash(State.SUCCESS, null, null, value, null).properties["Context"] + 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))