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"), + ) + } }