From a6ad12b36956c5b4e5aba8be21a42e9ffb82c311 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 14:59:03 -0400 Subject: [PATCH] feat(chat): send cash into a group chat as a cash link The $ button was hidden in group chats because a group has no single participant to pay. It now opens the chat's amount sheet, and on confirm the app funds a cash link and posts its bare URL into the group. The transcript already draws a link-only message as a cash card, so the card shows the amount and the first member to tap it claims the funds. The link is funded before anything is posted. If the post fails, the gift card's remote send is cancelled so the funds return. The send runs under NonCancellable so leaving the chat mid-send can't strand a funded link that was never posted. Funding and cancelling move out of GiftCardSharingDelegate into GiftCardFunding so the bill's "Send as Link" and the group send share one path. ChatCashLinks is a new entry point in :shared:session; ChatViewModel routes a group send to it and skips the tip-DM fee check. Analytics reports the group send as Send Cash Link with a new Cash Link Choice value, "Posted to group chat", since Sent Cash has no way to say where the cash went. --- .../app/messenger/internal/ChatViewModel.kt | 68 +++-- .../screens/components/ChatBottomBar.kt | 4 +- .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 268 ++++++++++++++++++ .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../com/flipcash/app/session/ChatCashLinks.kt | 33 +++ .../flipcash/app/session/inject/CoreModule.kt | 6 + .../delegates/ChatCashLinkDelegate.kt | 58 ++++ .../internal/delegates/GiftCardFunding.kt | 60 ++++ .../delegates/GiftCardSharingDelegate.kt | 49 +--- .../SessionControllerGiftCardErrorTest.kt | 4 +- .../delegates/ChatCashLinkDelegateTest.kt | 99 +++++++ .../delegates/GiftCardSharingDelegateTest.kt | 3 +- libs/analytics-events/EVENTS.md | 3 +- libs/analytics-events/events.toml | 3 +- .../flipcash/analytics/TransferEventsTest.kt | 15 + 16 files changed, 602 insertions(+), 73 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt create mode 100644 apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/ChatCashLinks.kt create mode 100644 apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegate.kt create mode 100644 apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardFunding.kt create mode 100644 apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegateTest.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 22e41d114b..daae25629b 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 @@ -14,6 +14,7 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding import com.flipcash.analytics.GroupInviteMethod @@ -45,6 +46,7 @@ import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.session.ChatCashLinks import com.flipcash.app.session.SettledClaim import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator @@ -177,6 +179,7 @@ internal class ChatViewModel @Inject constructor( private val linkCardClassifier: LinkCardClassifier, private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, + private val chatCashLinks: ChatCashLinks, private val chatDraftStore: ChatDraftStore, dispatchers: DispatcherProvider, ) : BaseViewModel( @@ -1933,9 +1936,10 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) eventFlow.filterIsInstance() - // Both contact DMs and tip DMs can send cash; the recipient is whichever participant - // backs the chat. The final send branches on that type (see Event.OnSendRequested). - .filter { stateFlow.value.participant != null } + // Contact DMs and tip DMs send to whichever participant backs the chat; a group has no + // participant and sends a cash link instead. The final send branches on that type (see + // Event.OnSendRequested). + .filter { stateFlow.value.participant != null || stateFlow.value.chatType == ChatType.GROUP } .onEach { if (!tokenCoordinator.hasGiveableBalance()) { if (!tokenCoordinator.hasBalance()) { @@ -1947,8 +1951,9 @@ internal class ChatViewModel @Inject constructor( } // The payment that opens a tip DM costs exactly the recipient's fee, so there is // nothing to enter — send it straight to the sheet that states the fee. Every - // other send, including the moment before the fee resolves, keeps the keypad. - val fee = minAmountFlow.value + // other send, including the moment before the fee resolves, keeps the keypad. A + // group has no one to charge a fee, so it always gets the keypad. + val fee = minAmountFlow.value.takeUnless { stateFlow.value.chatType == ChatType.GROUP } if (fee != null) { dispatchEvent(Event.NavigateToInitPayment) } else { @@ -1968,8 +1973,9 @@ internal class ChatViewModel @Inject constructor( // Send cash. The transfer itself is delegated by chat type: a contact DM pays a phone // number (contact metadata), a tip DM pays a user id (tip metadata). Both delegates resolve - // the recipient, transfer, debit the local balance, and sync the feed; this handler owns the - // shared amount verification, send state, analytics, and error UI. + // the recipient, transfer, debit the local balance, and sync the feed. A group funds a cash + // link and posts it (ChatCashLinks). This handler owns the shared amount verification, send + // state, analytics, and error UI. eventFlow.filterIsInstance() .onEach { (amount, token) -> viewModelScope.launch { @@ -2056,41 +2062,49 @@ internal class ChatViewModel @Inject constructor( origin = if (tipAction == TipAction.TIP) TipOrigin.TIPCARD else TipOrigin.CHAT, action = tipAction, ) + // A group has no one participant to pay, so the cash goes in as a link + // that the first member to tap claims. null -> { - dispatchEvent(Event.SendStateUpdated()) - return@launch + if (chatId == null || stateFlow.value.chatType != ChatType.GROUP) { + dispatchEvent(Event.SendStateUpdated()) + return@launch + } + chatCashLinks.sendToChat( + chatId = chatId, + amount = verifiedFiat, + token = token, + owner = owner, + ) } } - // Report what was sent — the tip call to action above is a tip, and every - // other send from this screen (contact DM or unlocked tip DM) is a plain cash - // send. Same `tipAction` the wire `action` above was set from, not a second - // "is this a tip" check that could drift from it. + // Report what was sent. A group's send is a cash link. Otherwise the tip call + // to action above is a tip, and every other send from this screen (contact DM + // or unlocked tip DM) is a plain cash send. Same `tipAction` the wire `action` + // above was set from, not a second "is this a tip" check that could drift + // from it. + val isCashLink = stateFlow.value.participant == null val isTip = tipAction == TipAction.TIP + val sent = { state: AnalyticsState, error: String? -> + val sentAmount = verifiedFiat.localFiat.analytics + when { + isCashLink -> TransferEvents.sendCashLink(state, sentAmount, CashLinkChoice.GROUP_CHAT, null, error) + isTip -> TransferEvents.sentTip(state, sentAmount, error) + else -> TransferEvents.sentCash(state, sentAmount, error) + } + } result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) delay(400.milliseconds) - analytics.track( - if (isTip) { - TransferEvents.sentTip(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) - } else { - TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) - } - ) + analytics.track(sent(AnalyticsState.SUCCESS, null)) dispatchEvent( Dispatchers.Main, Event.SendComplete(verifiedFiat.localFiat.nativeAmount) ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) - analytics.track( - if (isTip) { - TransferEvents.sentTip(AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics) - } else { - TransferEvents.sentCash(AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics) - } - ) + analytics.track(sent(AnalyticsState.FAILURE, cause.analytics)) BottomBarManager.showError( title = resources.getString(R.string.error_title_cashFailedToSend), message = resources.getString(R.string.error_description_cashFailedToSend), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index fe0b13faae..532df38831 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -252,9 +252,7 @@ internal fun UserControlBottomBar( CancelEditButton( onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, ) - } else if (state.chatType != ChatType.GROUP) { - // Sending cash into a group is not wired up yet, so the button is left - // out rather than shown and dead. The composer takes the row on its own. + } else { SendCashButton( state = state, hazeState = hazeState, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 204e527d77..593498b67b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -140,6 +140,7 @@ class ChatGroupAnalyticsTest { linkCardClassifier = linkCardClassifier, linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, + chatCashLinks = mockk(relaxed = true), chatDraftStore = chatDraftStore, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt new file mode 100644 index 0000000000..289deb12fb --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -0,0 +1,268 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.link.LinkCardClassifier +import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.session.ChatCashLinks +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +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.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A group has no participant to pay, so its send goes through [ChatCashLinks] rather than the + * contact or tip delegates. What the link carries and when it is cancelled is + * `ChatCashLinkDelegateTest`'s; this pins the routing and the event it reports. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatGroupCashLinkTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val contactCoordinator = mockk(relaxed = true) + private val contactPaymentDelegate = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) + private val purchaseMethodController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val clipboardManager = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) + private val linkCardClassifier = mockk(relaxed = true) + private val linkCardResolver = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) + private val chatCashLinks = mockk(relaxed = true) + private val chatDraftStore = mockk(relaxed = true) + + private val owner = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val chatId = ChatId(UUID.randomUUID().bytes) + private val amount = Fiat(5.0) + private val verifiedFiat = VerifiedFiat(LocalFiat(Fiat(5.0), Fiat(5.0))) + + @Before + fun setUp() { + BottomBarManager.clear() + + every { userManager.accountCluster } returns owner + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) + coEvery { tokenCoordinator.hasGiveableBalance(any()) } returns true + coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) + every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) + every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + coEvery { + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) + } returns Result.success(verifiedFiat) + coEvery { + contactCoordinator.resolve(any()) + } returns Result.success(mockk(relaxed = true)) + coEvery { chatCashLinks.sendToChat(any(), any(), any(), any()) } returns Result.success(Unit) + coEvery { + contactPaymentDelegate.send(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + coEvery { + tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) + } returns Result.success(null) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + contactCoordinator = contactCoordinator, + contactPaymentDelegate = contactPaymentDelegate, + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, + purchaseMethodController = purchaseMethodController, + userManager = userManager, + resources = resources, + analytics = analytics, + clipboardManager = clipboardManager, + userFlags = userFlags, + linkCardClassifier = linkCardClassifier, + linkCardResolver = linkCardResolver, + cashLinkClaims = cashLinkClaims, + chatCashLinks = chatCashLinks, + chatDraftStore = chatDraftStore, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + private fun ChatViewModel.openGroup() { + dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + dispatchEvent( + ChatViewModel.Event.OnGroupResolved( + ChatMembership( + metadata = mockk(relaxed = true) { + every { this@mockk.chatId } returns this@ChatGroupCashLinkTest.chatId + every { rules } returns null + }, + isMember = true, + ) + ) + ) + } + + private fun TestScope.recordEvents(vm: ChatViewModel): List { + val events = mutableListOf() + backgroundScope.launch(mainCoroutineRule.dispatcher) { vm.eventFlow.toList(events) } + return events + } + + private fun text(value: String) = PropertyValue.Text(value) + + @Test + fun `send cash in a group opens amount entry`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + val events = recordEvents(vm) + vm.openGroup() + + vm.dispatchEvent(ChatViewModel.Event.OnSendCash) + advanceUntilIdle() + + assertTrue(ChatViewModel.Event.NavigateToAmountEntry in events) + assertTrue(ChatViewModel.Event.NavigateToInitPayment !in events) + } + + @Test + fun `a group send posts a cash link and reports it`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + val events = recordEvents(vm) + vm.openGroup() + + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCashLinks.sendToChat(chatId, verifiedFiat, token, owner) } + coVerify(exactly = 0) { contactPaymentDelegate.send(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) } + assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) + + val event = analytics.events.single { it.name == "Send Cash Link" } + assertEquals(text(AnalyticsState.SUCCESS.value), event.properties["State"]) + assertEquals(text("Posted to group chat"), event.properties["Cash Link Choice"]) + assertTrue(analytics.events.none { it.name == "Sent Cash" || it.name == "Sent Tip" }) + } + + @Test + fun `a failed group send reports State Failure with an Error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { + chatCashLinks.sendToChat(any(), any(), any(), any()) + } returns Result.failure(RuntimeException("offline")) + + val vm = createViewModel() + val events = recordEvents(vm) + vm.openGroup() + + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + assertTrue(events.none { it is ChatViewModel.Event.SendComplete }) + val event = analytics.events.single { it.name == "Send Cash Link" } + assertEquals(text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertTrue(event.properties.containsKey("Error")) + } + + @Test + fun `a contact send still pays the contact`() = runTest(mainCoroutineRule.dispatcher) { + val contact = DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + vm.dispatchEvent(ChatViewModel.Event.OnContactFound(contact)) + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + coVerify(exactly = 1) { contactPaymentDelegate.send(contact, chatId, verifiedFiat, token, any()) } + coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } + assertEquals(1, analytics.events.count { it.name == "Sent Cash" }) + assertTrue(analytics.events.none { it.name == "Send Cash Link" }) + } + + @Test + fun `a tip send still pays the user`() = runTest(mainCoroutineRule.dispatcher) { + val userId: ID = UUID.randomUUID().bytes + + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.OnTipUserResolved(userId, mockk(relaxed = true))) + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + coVerify(exactly = 1) { tipPaymentDelegate.send(userId, verifiedFiat, token, any(), any(), any()) } + coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } + assertTrue(analytics.events.none { it.name == "Send Cash Link" }) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 8c63e3cace..193d1da310 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -132,6 +132,7 @@ class ChatSendFailureAnalyticsTest { linkCardClassifier = linkCardClassifier, linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, + chatCashLinks = mockk(relaxed = true), chatDraftStore = chatDraftStore, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/ChatCashLinks.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/ChatCashLinks.kt new file mode 100644 index 0000000000..83b09f2c95 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/ChatCashLinks.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.session + +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.Token + +/** + * Sends cash into a chat as a cash link: the app funds the link and posts its URL as an ordinary + * text message, which the transcript draws as a cash card. + * + * This is how a group chat sends cash. A group has no single recipient to transfer to, so the + * first member to tap the card claims it. + * + * Separate from [SessionController] for the same reason [CashLinkClaims] is: chat needs this one + * call, not the whole session. + */ +interface ChatCashLinks { + /** + * Funds a new cash link with [amount] of [token] from [owner], then posts the link to [chatId]. + * + * Nothing is posted unless the link is funded. If the post fails, the link is cancelled and its + * funds return to [owner]; the result is a failure either way. Once started, the send runs to + * completion even if the caller is cancelled, so leaving the chat mid-send cannot strand a + * funded link that was never posted. + */ + suspend fun sendToChat( + chatId: ChatId, + amount: VerifiedFiat, + token: Token, + owner: AccountCluster, + ): Result +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt index 9b736320e2..5b562fa862 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/inject/CoreModule.kt @@ -2,9 +2,11 @@ package com.flipcash.app.session.inject import com.flipcash.app.core.toast.ToastController import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.session.ChatCashLinks import com.flipcash.app.session.SessionController import com.flipcash.app.session.internal.RealSessionController import com.flipcash.app.session.internal.delegates.CashLinkDelegate +import com.flipcash.app.session.internal.delegates.ChatCashLinkDelegate import com.flipcash.app.session.internal.toast.SessionToastController import dagger.Binds import dagger.Module @@ -31,4 +33,8 @@ abstract class SessionModule { @Binds @Singleton abstract fun bindCashLinkClaims(impl: CashLinkDelegate): CashLinkClaims + + @Binds + @Singleton + abstract fun bindChatCashLinks(impl: ChatCashLinkDelegate): ChatCashLinks } \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegate.kt new file mode 100644 index 0000000000..07f6d7fbb6 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegate.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.core.util.Linkify +import com.flipcash.app.session.ChatCashLinks +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.accounts.entropy +import com.getcode.opencode.model.financial.Token +import com.getcode.utils.trace +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Posts a funded cash link into a chat. See [ChatCashLinks]. + * + * The message is the URL and nothing else. The transcript draws a message that is only a link as + * the link's card, and the card shows the amount, so any text around the URL would be drawn as a + * second bubble saying less than the card does. + */ +@Singleton +class ChatCashLinkDelegate @Inject constructor( + private val funding: GiftCardFunding, + private val chatCoordinator: ChatCoordinator, +) : ChatCashLinks { + + override suspend fun sendToChat( + chatId: ChatId, + amount: VerifiedFiat, + token: Token, + owner: AccountCluster, + ): Result = withContext(NonCancellable) { + val verifiedState = amount.verifiedState + ?: return@withContext Result.failure(IllegalStateException("Cash link amount has no verified state")) + + val giftCard = GiftCardAccount.create(token) + + funding.fund(giftCard, owner, amount.localFiat, token, verifiedState) + .onFailure { return@withContext Result.failure(it) } + + chatCoordinator.sendMessage(chatId, Linkify.cashLink(giftCard.entropy)) + .map { } + .onFailure { cause -> + trace(tag = "Session", message = "Cash link post failed; cancelling the link", error = cause) + funding.cancel(owner, giftCard).onFailure { + trace( + tag = "Session", + message = "Cancelling an unposted cash link failed; its funds return on expiry", + error = it, + ) + } + } + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardFunding.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardFunding.kt new file mode 100644 index 0000000000..8265686f7e --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardFunding.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.tokens.TokenCoordinator +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * Funds a cash link's gift card and takes it back, keeping the local balance in step. + * + * Shared by the two ways a cash link leaves the app: the bill's "Send as Link", which shares it + * ([GiftCardSharingDelegate]), and a group chat, which posts it ([ChatCashLinkDelegate]). Neither + * call presents anything; each caller owns what the user sees when a step fails. + */ +@Singleton +class GiftCardFunding @Inject constructor( + private val billController: BillController, + private val tokenCoordinator: TokenCoordinator, + private val transactionController: TransactionController, +) { + + /** Funds [giftCard] with [amount] and debits the local balance once the intent lands. */ + suspend fun fund( + giftCard: GiftCardAccount, + owner: AccountCluster, + amount: LocalFiat, + token: Token, + verifiedState: VerifiedState, + ): Result = suspendCancellableCoroutine { cont -> + billController.fundGiftCard( + giftCard = giftCard, + amount = amount, + token = token, + owner = owner, + verifiedState = verifiedState, + onFunded = { + tokenCoordinator.subtract(token, amount) + cont.resume(Result.success(it)) + }, + onError = { cont.resume(Result.failure(it)) }, + ) + } + + /** Cancels [giftCard]'s remote send, returning its funds to [owner], and refreshes balances. */ + suspend fun cancel( + owner: AccountCluster, + giftCard: GiftCardAccount, + ): Result = transactionController.cancelRemoteSend( + vault = giftCard.cluster.vaultPublicKey, + owner = owner, + ).onSuccess { tokenCoordinator.update() } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt index e722d092a1..e29bd6a915 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt @@ -16,12 +16,10 @@ import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.app.shareable.ShareableConfirmationController -import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.GiftCardAccount @@ -38,11 +36,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.resume import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -73,8 +69,7 @@ class GiftCardSharingDelegate @Inject constructor( private val shareSheetController: ShareSheetController, private val shareConfirmationController: ShareableConfirmationController, private val toastController: SessionToastController, - private val tokenCoordinator: TokenCoordinator, - private val transactionController: TransactionController, + private val funding: GiftCardFunding, private val analytics: FlipcashAnalytics, private val vibrator: Vibrator, private val resources: ResourceHelper, @@ -259,42 +254,22 @@ class GiftCardSharingDelegate @Inject constructor( amount: LocalFiat, token: Token, verifiedState: VerifiedState, - ): Result = suspendCancellableCoroutine { cont -> - billController.fundGiftCard( - giftCard = giftCard, - amount = amount, - token = token, - owner = owner, - verifiedState = verifiedState, - onFunded = { - tokenCoordinator.subtract(token, amount) - shareSheetController.reset() - cont.resume(Result.success(it)) - }, - onError = { - _events.trySend(Event.DismissBill(PutInWallet)) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToCreateGiftCard), - message = resources.getString(R.string.error_description_failedToCreateGiftCard) - ) - cont.resume(Result.failure(it)) - } - ) - } + ): Result = funding.fund(giftCard, owner, amount, token, verifiedState) + .onSuccess { shareSheetController.reset() } + .onFailure { + _events.trySend(Event.DismissBill(PutInWallet)) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToCreateGiftCard), + message = resources.getString(R.string.error_description_failedToCreateGiftCard) + ) + } private suspend fun cancelGiftCard( owner: AccountCluster, giftCard: GiftCardAccount ) { - transactionController.cancelRemoteSend( - vault = giftCard.cluster.vaultPublicKey, - owner = owner, - ).onFailure { - _events.trySend(Event.DismissBill(PutInWallet)) - }.onSuccess { - tokenCoordinator.update() - _events.trySend(Event.DismissBill(PutInWallet)) - } + funding.cancel(owner, giftCard) + _events.trySend(Event.DismissBill(PutInWallet)) } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 256f76eae8..4a7ddccfbb 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.internal.delegates.BillPresentationDelegate import com.flipcash.app.session.internal.delegates.CashLinkDelegate import com.flipcash.app.session.internal.delegates.CodeScanDelegate +import com.flipcash.app.session.internal.delegates.GiftCardFunding import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController @@ -114,8 +115,7 @@ class SessionControllerGiftCardErrorTest { shareSheetController = shareSheetController, shareConfirmationController = mockk(relaxed = true), toastController = mockk(relaxed = true), - tokenCoordinator = tokenCoordinator, - transactionController = mockk(relaxed = true), + funding = GiftCardFunding(billController, tokenCoordinator, mockk(relaxed = true)), analytics = analytics, vibrator = mockk(relaxed = true), resources = resources, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegateTest.kt new file mode 100644 index 0000000000..0295e9c45d --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/ChatCashLinkDelegateTest.kt @@ -0,0 +1,99 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.UUID +import kotlin.test.assertTrue + +class ChatCashLinkDelegateTest { + + private val funding = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val owner = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val giftCard = mockk(relaxed = true) + private val chatId = ChatId(UUID.randomUUID().bytes) + private val localFiat = LocalFiat(Fiat(5.0), Fiat(5.0)) + private val amount = VerifiedFiat(localFiat, verifiedState = mockk(relaxed = true)) + + private val delegate = ChatCashLinkDelegate(funding, chatCoordinator) + + @Before + fun setUp() { + mockkObject(GiftCardAccount.Companion) + every { GiftCardAccount.create(any(), any()) } returns giftCard + every { giftCard.mnemonic.getBase58EncodedEntropy() } returns ENTROPY + coEvery { funding.fund(any(), any(), any(), any(), any()) } returns Result.success(localFiat) + coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.success(mockk(relaxed = true)) + } + + @After + fun tearDown() { + unmockkObject(GiftCardAccount.Companion) + } + + @Test + fun `funds the link, then posts only its URL`() = runTest { + val result = delegate.sendToChat(chatId, amount, token, owner) + + assertTrue(result.isSuccess) + coVerifyOrder { + funding.fund(giftCard, owner, localFiat, token, any()) + chatCoordinator.sendMessage(chatId, "https://send.flipcash.com/c/#/e=$ENTROPY", null) + } + coVerify(exactly = 0) { funding.cancel(any(), any()) } + } + + @Test + fun `a failed fund posts nothing`() = runTest { + coEvery { funding.fund(any(), any(), any(), any(), any()) } returns Result.failure(RuntimeException("rejected")) + + val result = delegate.sendToChat(chatId, amount, token, owner) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { chatCoordinator.sendMessage(any(), any(), any()) } + coVerify(exactly = 0) { funding.cancel(any(), any()) } + } + + @Test + fun `a failed post cancels the link`() = runTest { + coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.failure(RuntimeException("offline")) + + val result = delegate.sendToChat(chatId, amount, token, owner) + + assertTrue(result.isFailure) + coVerify(exactly = 1) { funding.cancel(owner, giftCard) } + } + + @Test + fun `an amount without a verified state neither funds nor posts`() = runTest { + val result = delegate.sendToChat(chatId, VerifiedFiat(localFiat), token, owner) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { funding.fund(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { chatCoordinator.sendMessage(any(), any(), any()) } + } + + private companion object { + const val ENTROPY = "4sRnD8f3pk" + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt index d3c25fd665..bcab6630bb 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt @@ -118,8 +118,7 @@ class GiftCardSharingDelegateTest { shareSheetController = shareSheet, shareConfirmationController = shareConfirmationController, toastController = toastController, - tokenCoordinator = tokenCoordinator, - transactionController = transactionController, + funding = GiftCardFunding(billController, tokenCoordinator, transactionController), analytics = analytics, vibrator = vibrator, resources = resources, diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 1423a0d382..3a31fc9d72 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -204,7 +204,7 @@ No properties. | amount | group | yes | [amount](#amount) | | Error | text | yes | | -> **Drift:** iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure. +> **Drift:** iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android sends Failure only for a link posted to a group chat. ### Receive Cash Link @@ -559,6 +559,7 @@ The `Cash Link Choice` property on Send Cash Link: how the user passed the link |---|---|---| | Copied to clipboard | `COPIED` | | | Shared to app | `SHARED` | | +| Posted to group chat | `GROUP_CHAT` | | ### AddMoneySource diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 591f6309f5..7eeca8bc8d 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -78,6 +78,7 @@ description = "The `Cash Link Choice` property on Send Cash Link: how the user p [lists.CashLinkChoice.values] COPIED = "Copied to clipboard" SHARED = "Shared to app" +GROUP_CHAT = "Posted to group chat" [lists.AddMoneySource] description = "The `Source` property on Add Money: Opened: where the user opened Add Money from." @@ -417,7 +418,7 @@ properties = [ { name = "App", type = "text", optional = true }, { group = "outcome" }, ] -drift = "iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure." +drift = "iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android sends Failure only for a link posted to a group chat." [[event]] domain = "Transfer" 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 508d4eb286..54eb903d96 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 @@ -83,4 +83,19 @@ class TransferEventsTest { TransferEvents.sendCashLink(State.SUCCESS, null, CashLinkChoice.SHARED, "com.whatsapp", null), ) } + + @Test + fun sendCashLinkToAGroupChatThatFailed() { + assertEquals( + AnalyticsEvent( + "Send Cash Link", + mapOf( + "Cash Link Choice" to PropertyValue.Text("Posted to group chat"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("offline"), + ), + ), + TransferEvents.sendCashLink(State.FAILURE, null, CashLinkChoice.GROUP_CHAT, null, "offline"), + ) + } }