diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 0e00a6b5f6..aa61563a7d 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1987,6 +1987,8 @@ Select People + Split Between + Total Amount To Send Members Send to %1$d Person @@ -2009,8 +2011,13 @@ to %1$d person to %1$d people - %1$s each - ~%1$s each + %1$s Each + ~%1$s Each + + %1$s each + ~%1$s each + %1$s Each, including You + ~%1$s Each, including You %1$s sent Sent From diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 4680aa9b02..059ba04e2b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -185,10 +185,10 @@ private fun chatEntryProvider( FlowConversationScreen(openKeyboard) } annotatedEntry { - FlowAmountEntryScreen(resultStateRegistry) + FlowAmountEntryScreen(resultStateRegistry, toMembers = false) } annotatedEntry { - FlowAmountEntryScreen(resultStateRegistry) + FlowAmountEntryScreen(resultStateRegistry, toMembers = true) } annotatedEntry { FlowGroupInviteSheet() @@ -427,7 +427,7 @@ private fun FlowConversationScreen( } @Composable -private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) { +private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry, toMembers: Boolean) { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() // The chat flow's navigator, read before the sheet's own FlowHost below shadows it. @@ -466,6 +466,8 @@ private fun FlowAmountEntryScreen(resultStateRegistry: NavResultStateRegistry) { onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation onExit = dismissSheet, + // A group send picks its currency on the confirm sheet, so no pill here. + title = if (toMembers) stringResource(R.string.title_groupSendTotal) else null, ) } } @@ -494,11 +496,15 @@ private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () - onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, + you = state.groupSendViewer, + buttonEnabled = groupSend.paid.isNotEmpty(), + title = stringResource(R.string.title_splitBetween), + buttonText = stringResource(R.string.action_next), ) if (groupSend.stage == GroupSendState.Stage.Confirm) { GroupSendConfirmSheet( groupSend = groupSend, - recipients = groupSend.selected.mapNotNull { id -> candidates.find { it.userId == id } }, + recipients = groupSend.paid.mapNotNull { id -> candidates.find { it.userId == id } }, swipePay = SwipePayUiState( token = state.token, progress = state.sendProgress, 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 67cd3d42ca..3931fe73e9 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 @@ -175,6 +175,7 @@ import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.group.GroupSendPaymentDelegate import com.flipcash.shared.payments.group.GroupSendPlan +import com.flipcash.shared.payments.group.GroupSendSplit import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability @@ -685,6 +686,8 @@ internal class ChatViewModel @AssistedInject constructor( /** Who Select People lists: the roster, or what this device knows of it, less the viewer. */ val groupSendCandidates: List get() = groupSend?.candidates(members.ifEmpty { knownMembers }).orEmpty() + val groupSendViewer: ChatMember? + get() = groupSend?.viewer(members.ifEmpty { knownMembers }) // Opening the participant's profile (the entry point to blocking) is the tip arm's answer // alone. Asking the subject rather than comparing chat types means a new arm has to say. @@ -1936,13 +1939,17 @@ internal class ChatViewModel @AssistedInject constructor( ) } + // A group send has no ceiling: its currency is picked on the confirm sheet, which is the only + // place the balance is checked, so the amount step shows no "Enter up to" hint and no limit. private val maxAmountFlow by lazy { combine( transactionController.limits, tokenCoordinator.observeSelectedTokenMint() .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) }, exchange.observePreferredRate(), - ) { limits, balance, rate -> + stateFlow.map { it.groupSend != null }.distinctUntilChanged(), + ) { limits, balance, rate, isGroupSend -> + if (isGroupSend) return@combine null val balanceInLocal = balance.convertingTo(rate) val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency) @@ -4114,14 +4121,15 @@ internal class ChatViewModel @AssistedInject constructor( val chatId = stateFlow.value.chatId ?: return@launch val token = stateFlow.value.token ?: return@launch val owner = userManager.accountCluster ?: return@launch - // The confirm sheet can change the token after the amount step checked the balance. - if (checkBalanceLimit(total)) return@launch + // The sender pays the parts of everyone but themselves, which can be less than the total + // entered. The confirm sheet can change the token, so this is the only balance check. + if (checkBalanceLimit(GroupSendSplit.fiat(plan.parts.sum(), total.currencyCode))) return@launch dispatchEvent(Event.SendStateUpdated(loading = true)) val result = groupSendPayments.send( chatId = chatId, - recipients = groupSend.selected, + recipients = groupSend.paid, plan = plan, currency = total.currencyCode, token = token, @@ -4132,7 +4140,7 @@ internal class ChatViewModel @AssistedInject constructor( holdPaymentSuccess( show = { dispatchEvent(Event.SendStateUpdated(success = true)) }, release = { - analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null, SendContext.GROUP_SEND, groupSend.selected.size)) + analytics.track(TransferEvents.sentCash(AnalyticsState.SUCCESS, total.analytics, null, SendContext.GROUP_SEND, groupSend.paid.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)) @@ -4140,7 +4148,7 @@ internal class ChatViewModel @AssistedInject constructor( ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) - analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics, SendContext.GROUP_SEND, groupSend.selected.size)) + analytics.track(TransferEvents.sentCash(AnalyticsState.FAILURE, total.analytics, cause.analytics, SendContext.GROUP_SEND, groupSend.paid.size)) val (title, message) = when (cause) { is ComputeVerifiedFiatError.AmountBelowMinimum -> R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall @@ -4199,13 +4207,16 @@ internal class ChatViewModel @AssistedInject constructor( val enteredAmount = fixedAmount?.toDouble() ?: amountDelegate.state.value.enteredAmount val amount = fixedAmount ?: Fiat(enteredAmount, exchange.preferredRate.currency) - if (checkBalanceLimit(amount) || checkSendLimit(enteredAmount)) return + // A group send's balance check waits for the confirm sheet (sendToMembers), where the + // currency is picked; the amount here only has to be positive. + val groupSend = stateFlow.value.groupSend + val overBalance = groupSend == null && checkBalanceLimit(amount) + if (overBalance || checkSendLimit(enteredAmount)) return if (enteredAmount <= 0) return val token = stateFlow.value.token ?: return - val groupSend = stateFlow.value.groupSend if (groupSend != null && fixedAmount == null) { dispatchEvent( Event.GroupSendUpdated( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt index 1785a6ce33..f57bb7edc0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt @@ -21,17 +21,38 @@ internal data class GroupSendState( ) { enum class Stage { Amount, People, Confirm } - /** The parts to pay, once [total] is known and someone is checked. */ + /** + * Who is paid: [selected] without the sender. The sender can be checked to take a part of the + * split, but the contract requires every receiver to be another member. + */ + val paid: List + get() = selected.filterNot { it == viewerId } + + /** Whether the sender is checked, which counts them in the split without paying them. */ + val includesViewer: Boolean + get() = viewerId != null && viewerId in selected + + /** + * The parts to pay, once [total] is known and someone other than the sender is checked: the + * total is split between everyone checked, the sender included as the last share, then the + * sender's part is dropped. + */ val plan: GroupSendPlan - get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size) } - ?: GroupSendPlan.NoRecipients + get() { + val total = total ?: return GroupSendPlan.NoRecipients + return GroupSendSplit.splitWithViewer(GroupSendSplit.minorUnits(total), paid.size, includesViewer) + } - /** Who Select People lists: the roster without the sender, who never pays themselves. */ + /** Who Select People lists: the roster without the sender, who has a "You" row of their own. */ fun candidates(roster: List): List = roster.filter { it.userId != viewerId }.distinctBy { it.userId } + /** The sender's own row, from the roster, when they are in it. */ + fun viewer(roster: List): ChatMember? = roster.find { it.userId == viewerId } + /** - * [selected] with [member] checked or unchecked, or null when checking them would make an 11th. + * [selected] with [member] checked or unchecked, or null when checking them would make an 11th + * person. The sender counts toward the cap, as a share of the split. */ fun toggled(member: ID): List? = when { member in selected -> selected.filterNot { it == member } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index b560d12636..cd5b710bd9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.cash import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -15,6 +16,7 @@ import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.util.resources.LocalResources @@ -32,6 +34,8 @@ internal fun ChatAmountEntryContent( onExit: () -> Unit, onConfirm: () -> Unit, onSendComplete: (() -> Unit)? = null, + /** A fixed title in place of the currency pill, for a send whose currency is chosen later. */ + title: String? = null, ) { val navigator = LocalCodeNavigator.current val resources = LocalResources.current @@ -67,15 +71,19 @@ internal fun ChatAmountEntryContent( // the leading slot's width to position a Start-aligned title. titleAlignment = Alignment.CenterHorizontally, title = { - TokenSelectionPill( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - token = token - ) { - // A half sheet over this one, not the app's full-screen picker, so the - // amount stays in view underneath. - navigator.push(ChatStep.SendCurrency) + if (title != null) { + Text(text = title, style = CodeTheme.typography.screenTitle) + } else { + TokenSelectionPill( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + token = token + ) { + // A half sheet over this one, not the app's full-screen picker, so the + // amount stays in view underneath. + navigator.push(ChatStep.SendCurrency) + } } }, // A close X, not an up arrow: this is a sheet over the conversation, and dismissing diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 9b42cf5fc6..a24939214a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -75,16 +75,19 @@ internal fun GroupSelectPeopleContent( onBack: () -> Unit, onToggle: (ChatMember) -> Unit, onSend: () -> Unit, + title: String = stringResource(R.string.title_selectPeople), buttonText: String = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size), buttonEnabled: Boolean = selected.isNotEmpty(), /** Shown as "You" rather than by name, for a list the viewer is on. */ viewerId: ID? = null, + /** The sender's own row, first, for a split they can take a part of. Null leaves it out. */ + you: ChatMember? = null, ) { val selectedIds = selected.toSet() CodeScaffold( topBar = { AppBarWithTitle( - title = stringResource(R.string.title_selectPeople), + title = title, titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = onBack, ) @@ -103,7 +106,7 @@ internal fun GroupSelectPeopleContent( }, ) { innerPadding -> val unknownName = stringResource(R.string.title_unnamedUser) - val you = stringResource(R.string.label_selectPeople_you) + val youLabel = stringResource(R.string.label_selectPeople_you) LazyColumn( modifier = Modifier .fillMaxSize() @@ -120,11 +123,21 @@ internal fun GroupSelectPeopleContent( color = CodeTheme.colors.textSecondary, ) } + if (you != null) { + item(key = "you") { + MemberCheckRow( + member = you, + name = youLabel, + selected = you.userId in selectedIds, + onClick = { onToggle(you) }, + ) + } + } items(candidates, key = { it.userId }) { member -> MemberCheckRow( member = member, name = if (member.userId == viewerId) { - you + youLabel } else { chatterName(member.userProfile.displayName, member.userProfile.username, unknownName) }, @@ -197,7 +210,7 @@ internal fun GroupSendConfirmSheet( actions: SwipePayActions, ) { val total = groupSend.total ?: return - val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return + val pay = groupSend.plan as? GroupSendPlan.Pay ?: return val unknownName = stringResource(R.string.title_unnamedUser) SwipePaySheet( @@ -226,9 +239,9 @@ internal fun GroupSendConfirmSheet( style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, ) - if (parts.size >= 2) { + if (pay.showsEach) { Text( - text = eachLabel(parts, total.currencyCode), + text = eachLabel(pay, total.currencyCode), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) @@ -238,12 +251,20 @@ internal fun GroupSendConfirmSheet( ) } -/** "$1.00 Each" when the parts are equal, else "~$0.33 Each" for the smallest. */ +/** + * "$1.00 Each" when every part, the sender's included, is equal, else "~$0.33 Each" for the + * smallest paid part; ", including You" when the sender is in the split. + */ @Composable -private fun eachLabel(parts: List, currency: CurrencyCode): String { - val smallest = GroupSendSplit.fiat(parts.min(), currency).formatted() - return if (parts.distinct().size == 1) stringResource(R.string.label_groupSendEach, smallest) - else stringResource(R.string.label_groupSendEachApprox, smallest) +private fun eachLabel(pay: GroupSendPlan.Pay, currency: CurrencyCode): String { + val smallest = GroupSendSplit.fiat(pay.smallest, currency).formatted() + val res = when { + pay.includesViewer && pay.isEven -> R.string.label_groupSendEachIncludingYou + pay.includesViewer -> R.string.label_groupSendEachApproxIncludingYou + pay.isEven -> R.string.label_groupSendEach + else -> R.string.label_groupSendEachApprox + } + return stringResource(res, smallest) } /** The first few recipients' faces, overlapping, as in the design's pair of avatars. */ 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 index 0fa10d4bf2..0d34356f90 100644 --- 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 @@ -44,6 +44,7 @@ import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.verify import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -444,4 +445,152 @@ class ChatGroupCashLinkTest { coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) } + + @Test + fun `a group send total above the balance passes the amount step and stops at confirm`() = runTest(mainCoroutineRule.dispatcher) { + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(1.0) + val vm = createViewModel() + selectingPeople(vm, total = 5.0) + + // The amount step does not check the balance: it moves on to Select People. + assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage) + verify(exactly = 0) { startChattingPayer.presentInsufficientBalance(any()) } + + vm.check(2) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + assertEquals(GroupSendState.Stage.Confirm, vm.stateFlow.value.groupSend?.stage) + + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) + advanceUntilIdle() + + verify(exactly = 1) { startChattingPayer.presentInsufficientBalance(any()) } + coVerify(exactly = 0) { groupSendPayments.send(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `a group send has no hint or ceiling on the amount step`() = runTest(mainCoroutineRule.dispatcher) { + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(1.0) + val vm = createViewModel() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.amountDelegate.config.collect { } } + vm.openGroup(isPrivate = true) + enterAmount(vm, 5.0) + advanceUntilIdle() + val hint = vm.amountDelegate.config.value.hint + assertEquals(com.flipcash.shared.amountentry.AmountEntryHint.None, hint) + assertTrue(vm.amountDelegate.config.value.canConfirm) + } + + private fun ChatViewModel.checkYou() = + dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + + private fun ChatViewModel.checkMember(index: Int) = + dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(roster[index].userId)) + + @Test + fun `thirty dollars between You and two others pays ten each and charges twenty`() = runTest(mainCoroutineRule.dispatcher) { + // Within the balance of the paid parts ($20) but not of the entered total ($30). + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(25.0) + val vm = createViewModel() + selectingPeople(vm, total = 30.0) + vm.checkYou(); vm.checkMember(0); vm.checkMember(1) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(GroupSendState.Stage.Confirm, groupSend?.stage) + assertEquals(Fiat(30.0), groupSend?.total) + assertEquals(GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L), groupSend?.plan) + + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) + advanceUntilIdle() + + verify(exactly = 0) { startChattingPayer.presentInsufficientBalance(any()) } + coVerify(exactly = 1) { + groupSendPayments.send( + chatId = chatId, + recipients = listOf(roster[0].userId, roster[1].userId), + plan = GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L), + currency = CurrencyCode.USD, + token = token, + source = any(), + ) + } + } + + @Test + fun `You is the last share whatever order it was checked in, so the others take the extra cent`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 1.0) + vm.checkYou(); vm.checkMember(0); vm.checkMember(1) + + assertEquals(GroupSendPlan.Pay(listOf(34L, 33L), viewerPart = 33L), vm.stateFlow.value.groupSend?.plan) + assertEquals(listOf(roster[0].userId, roster[1].userId), vm.stateFlow.value.groupSend?.paid) + } + + @Test + fun `only You checked pays no one and Next goes nowhere`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 1.0) + vm.checkYou() + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + advanceUntilIdle() + + val groupSend = vm.stateFlow.value.groupSend + assertTrue(groupSend?.paid?.isEmpty() == true) + assertEquals(GroupSendPlan.NoRecipients, groupSend?.plan) + assertEquals(GroupSendState.Stage.People, groupSend?.stage) + } + + @Test + fun `You counts toward the cap so nine others plus You is the most`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 20.0) + vm.checkYou() + vm.check(10) + advanceUntilIdle() + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(9, groupSend?.paid?.size) + assertEquals(10, groupSend?.selected?.size) + assertTrue(groupSend?.plan is GroupSendPlan.Pay) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `the too-small check counts You in the split`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 0.05) + vm.checkYou(); vm.check(5) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + advanceUntilIdle() + + assertEquals(GroupSendPlan.PartBelowMinimum, vm.stateFlow.value.groupSend?.plan) + assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `ten dollars between You and one member says only five is sent, including You`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 10.0) + vm.checkYou(); vm.checkMember(0) + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(Fiat(10.0), groupSend?.total) + val pay = groupSend?.plan as GroupSendPlan.Pay + assertEquals(listOf(500L), pay.parts) + assertEquals(500L, pay.viewerPart) + // The Each line shows for one paid member when You are in, as "$5.00 Each, including You". + assertTrue(pay.showsEach && pay.includesViewer && pay.isEven) + assertEquals(500L, pay.smallest) + } + + @Test + fun `one paid member without You has no Each line`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 10.0) + vm.checkMember(0) + + val pay = vm.stateFlow.value.groupSend?.plan as GroupSendPlan.Pay + assertTrue(!pay.showsEach) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index ab4e9b71c5..3417d4ab3e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1216,7 +1216,7 @@ private fun GroupSendBubble( val even = parts.all { it.nativeAmount == smallest.nativeAmount } Text( text = stringResource( - if (even) R.string.label_groupSendEach else R.string.label_groupSendEachApprox, + if (even) R.string.label_groupBubbleEach else R.string.label_groupBubbleEachApprox, content.partInDisplayCurrency(smallest).formatted(), ), style = secondary, diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt index bb6df9fa71..28fa7e1da2 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendPaymentDelegate.kt @@ -21,7 +21,9 @@ import javax.inject.Singleton * single payment, 2 to 10 are one multi-payment in selection order. Both carry group-payment app * metadata, so the server posts the message into the group. * - * No UI. Balance and send-limit checks on the total are the caller's, before it calls [send]. + * No UI. The caller checks the balance against the sum of [GroupSendPlan.Pay.parts] (what the sender + * pays, which is less than the entered total when the sender is in the split) and any send limit, + * before it calls [send]. */ @Singleton class GroupSendPaymentDelegate @Inject constructor( diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt index bb556126a6..e21b0baa63 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt @@ -10,10 +10,18 @@ import java.math.RoundingMode sealed interface GroupSendPlan { /** * Pay [parts], in the currency's minor unit and in selection order. One part is a single - * payment; two or more are one multi-payment. + * payment; two or more are one multi-payment. [viewerPart] is the sender's own part when they + * are in the split: not paid, and kept out of [parts]. */ - data class Pay(val parts: List) : GroupSendPlan { - val isEven: Boolean get() = parts.all { it == parts.first() } + data class Pay(val parts: List, val viewerPart: Long? = null) : GroupSendPlan { + /** Whether the sender is in the split: their part was dropped, and stays with them. */ + val includesViewer: Boolean get() = viewerPart != null + + /** Every part equal, the sender's included, so "Each" needs no "~". */ + val isEven: Boolean get() = (parts + listOfNotNull(viewerPart)).all { it == parts.first() } + + /** Whether the confirm sheet has an "Each" line: with several paid, or to say You are in. */ + val showsEach: Boolean get() = parts.size >= 2 || includesViewer /** The part shown as "Each": every part when [isEven], the smallest otherwise. */ val smallest: Long get() = parts.min() @@ -50,6 +58,20 @@ object GroupSendSplit { return GroupSendPlan.Pay(List(recipients) { i -> if (i < leftover) base + 1 else base }) } + /** + * A split that the sender is part of but is not paid: [totalUnits] is divided between the + * [paid] members and the sender, whose part is always the last share, so they never take an + * extra unit. The sender's part is then dropped: the result's parts are the paid members', + * in check order. [includeViewer] false is a plain split. The sender counts toward + * [MAX_RECIPIENTS], and in the too-small rule. + */ + fun splitWithViewer(totalUnits: Long, paid: Int, includeViewer: Boolean): GroupSendPlan { + if (paid <= 0) return GroupSendPlan.NoRecipients + val split = split(totalUnits, paid + if (includeViewer) 1 else 0) + if (split !is GroupSendPlan.Pay || !includeViewer) return split + return GroupSendPlan.Pay(parts = split.parts.dropLast(1), viewerPart = split.parts.last()) + } + /** [fiat] in its currency's minor unit, rounded half-up to that unit. */ fun minorUnits(fiat: Fiat): Long = BigDecimal.valueOf(fiat.quarks) diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt index 2f0c5404a4..fd5e2e1f1f 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt @@ -59,6 +59,44 @@ class GroupSendSplitVectorsTest { } } + @Test + fun `every You vector`() { + val vectors = fixture.getJSONArray("youVectors") + assertEquals(9, vectors.length()) + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val name = v.getString("name") + val total = cents(v.getString("total")) + val plan = GroupSendSplit.splitWithViewer(total, v.getInt("recipients"), includeViewer = true) + + if (v.has("expectedError")) { + val error = when (val rule = v.getString("expectedError")) { + "part-below-minimum" -> GroupSendPlan.PartBelowMinimum + "too-many-recipients" -> GroupSendPlan.TooManyRecipients + "no-recipients" -> GroupSendPlan.NoRecipients + else -> error("$name: unknown rule $rule") + } + assertEquals(error, plan, name) + continue + } + + val pay = assertIs(plan, name) + val intent = if (pay.parts.size > 1) "SendPublicMultiPayment" else "SendPublicPayment" + assertEquals(v.getString("intent"), intent, name) + + val parts = v.getJSONArray("parts") + assertEquals((0 until parts.length()).map { cents(parts.getString(it)) }, pay.parts, name) + assertEquals(cents(v.getString("paidTotal")), pay.parts.sum(), name) + assertEquals(cents(v.getString("youPart")), total - pay.parts.sum(), name) + + assertEquals(v.has("eachLabel"), pay.showsEach, name) + if (v.has("eachLabel")) { + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each, including You" + assertEquals(v.getString("eachLabel"), if (pay.isEven) each else "~$each", name) + } + } + } + @Test fun `minor units round trip through Fiat`() { assertEquals(12345L, GroupSendSplit.minorUnits(Fiat("123.45", CurrencyCode.USD))) diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index 00685790ca..3b0e8b0ce2 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -177,5 +177,116 @@ "recipients": 0, "expectedError": "no-recipients" } + ], + "youVectors": [ + { + "name": "you and two even", + "total": "30.00", + "recipients": 2, + "intent": "SendPublicMultiPayment", + "youPart": "10.00", + "parts": [ + "10.00", + "10.00" + ], + "paidTotal": "20.00", + "eachLabel": "$10.00 Each, including You" + }, + { + "name": "others take the extra cents", + "total": "1.00", + "recipients": 2, + "intent": "SendPublicMultiPayment", + "youPart": "0.33", + "parts": [ + "0.34", + "0.33" + ], + "paidTotal": "0.67", + "eachLabel": "~$0.33 Each, including You" + }, + { + "name": "you and one", + "total": "5.01", + "recipients": 1, + "intent": "SendPublicPayment", + "youPart": "2.50", + "parts": [ + "2.51" + ], + "paidTotal": "2.51", + "eachLabel": "~$2.51 Each, including You" + }, + { + "name": "nine plus you", + "total": "10.00", + "recipients": 9, + "intent": "SendPublicMultiPayment", + "youPart": "1.00", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00" + ], + "paidTotal": "9.00", + "eachLabel": "$1.00 Each, including You" + }, + { + "name": "nine plus you uneven", + "total": "1.01", + "recipients": 9, + "intent": "SendPublicMultiPayment", + "youPart": "0.10", + "parts": [ + "0.11", + "0.10", + "0.10", + "0.10", + "0.10", + "0.10", + "0.10", + "0.10", + "0.10" + ], + "paidTotal": "0.91", + "eachLabel": "~$0.10 Each, including You" + }, + { + "name": "you exact minimum", + "total": "0.03", + "recipients": 2, + "intent": "SendPublicMultiPayment", + "youPart": "0.01", + "parts": [ + "0.01", + "0.01" + ], + "paidTotal": "0.02", + "eachLabel": "$0.01 Each, including You" + }, + { + "name": "too small with you", + "total": "0.02", + "recipients": 2, + "expectedError": "part-below-minimum" + }, + { + "name": "only you", + "total": "1.00", + "recipients": 0, + "expectedError": "no-recipients" + }, + { + "name": "ten plus you", + "total": "11.00", + "recipients": 10, + "expectedError": "too-many-recipients" + } ] }