From e49ebdd57a72023a2a0716080393a8c116680d35 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:07:36 -0400 Subject: [PATCH 1/7] feat(group-send): pick the currency on the confirm sheet only The group send amount step drops the currency pill, the "Enter up to" hint and the balance ceiling, and is titled "Total Amount To Send". Select People is titled "Split Between" and ends on Next. The confirm sheet is unchanged and is now the only balance check. --- .../src/androidMain/res/values/strings.xml | 2 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 10 ++++-- .../app/messenger/internal/ChatViewModel.kt | 13 +++++-- .../screens/cash/ChatAmountEntryScreen.kt | 26 +++++++++----- .../internal/screens/cash/GroupSendScreens.kt | 3 +- .../internal/ChatGroupCashLinkTest.kt | 35 +++++++++++++++++++ 6 files changed, 73 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 0e00a6b5f6..ccf6f16275 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 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..5a377de0f1 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,6 +496,8 @@ 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) }, + title = stringResource(R.string.title_splitBetween), + buttonText = stringResource(R.string.action_next), ) if (groupSend.stage == GroupSendState.Stage.Confirm) { GroupSendConfirmSheet( 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..a838aea995 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 @@ -1936,13 +1936,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) @@ -4199,13 +4203,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/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..17ab621fb5 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,6 +75,7 @@ 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. */ @@ -84,7 +85,7 @@ internal fun GroupSelectPeopleContent( CodeScaffold( topBar = { AppBarWithTitle( - title = stringResource(R.string.title_selectPeople), + title = title, titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = onBack, ) 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..9931a893e1 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,38 @@ 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) + } } From 8c477fa92c2bcdfada24dea7d92a4bb17a78753a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:26:39 -0400 Subject: [PATCH 2/7] feat(group-send): add a You row to Split Between Checking You counts the sender in the split without paying them: the total is split between everyone checked in check order, then You's part is dropped. Only the other members are paid, Next needs at least one of them, and You does not count toward the 10 cap. Confirm lists only the paid members, and the balance check is against the sum of the paid parts. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 4 +- .../app/messenger/internal/ChatViewModel.kt | 14 +-- .../internal/groupsend/GroupSendState.kt | 42 +++++++-- .../internal/screens/cash/GroupSendScreens.kt | 16 +++- .../internal/ChatGroupCashLinkTest.kt | 88 +++++++++++++++++++ .../shared/payments/group/GroupSendSplit.kt | 8 +- 6 files changed, 156 insertions(+), 16 deletions(-) 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 5a377de0f1..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 @@ -496,13 +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 a838aea995..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. @@ -4118,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, @@ -4136,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)) @@ -4144,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 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..d94768c921 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,21 +21,51 @@ 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, in check order, 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 + if (paid.isEmpty()) return GroupSendPlan.NoRecipients + val split = GroupSendSplit.split( + GroupSendSplit.minorUnits(total), + selected.size, + maxRecipients = GroupSendSplit.MAX_RECIPIENTS + if (includesViewer) 1 else 0, + ) + if (split !is GroupSendPlan.Pay) return split + return GroupSendPlan.Pay( + split.parts.filterIndexed { i, _ -> selected[i] != viewerId } + ) + } - /** 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 + * paid member. The sender does not count toward the cap: they are not paid. */ fun toggled(member: ID): List? = when { member in selected -> selected.filterNot { it == member } - selected.size >= GroupSendSplit.MAX_RECIPIENTS -> null + member != viewerId && paid.size >= GroupSendSplit.MAX_RECIPIENTS -> null else -> selected.plusElement(member) } } 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 17ab621fb5..ccb2c80743 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 @@ -80,6 +80,8 @@ internal fun GroupSelectPeopleContent( 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( @@ -104,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() @@ -121,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) }, 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 9931a893e1..98ff12b357 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 @@ -479,4 +479,92 @@ class ChatGroupCashLinkTest { 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)), 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)), + currency = CurrencyCode.USD, + token = token, + source = any(), + ) + } + } + + @Test + fun `You checked first takes the extra cent and the others are paid the smaller parts`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 1.0) + vm.checkYou(); vm.checkMember(0); vm.checkMember(1) + + assertEquals(GroupSendPlan.Pay(listOf(33L, 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 `ten others plus You is allowed and an 11th other is not`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 20.0) + vm.checkYou() + vm.check(11) + advanceUntilIdle() + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(10, groupSend?.paid?.size) + assertEquals(11, 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) + } } 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..e093952fce 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 @@ -41,9 +41,13 @@ object GroupSendSplit { const val MAX_RECIPIENTS = 10 const val MINIMUM_PART_UNITS = 1L - fun split(totalUnits: Long, recipients: Int): GroupSendPlan { + /** + * @param maxRecipients the most the total may be split between. A split that includes the sender + * counts one more than the people paid, so it allows [MAX_RECIPIENTS] + 1. + */ + fun split(totalUnits: Long, recipients: Int, maxRecipients: Int = MAX_RECIPIENTS): GroupSendPlan { if (recipients <= 0) return GroupSendPlan.NoRecipients - if (recipients > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients + if (recipients > maxRecipients) return GroupSendPlan.TooManyRecipients val base = totalUnits / recipients if (base < MINIMUM_PART_UNITS) return GroupSendPlan.PartBelowMinimum val leftover = totalUnits % recipients From a4a8771b9f653115baf1d7e3bdf452a5f123cf14 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:35:28 -0400 Subject: [PATCH 3/7] test(group-send): run the You split vectors Adds the youVectors from the shared group-send-split.json fixture and a GroupSendSplit.splitWithViewer that GroupSendState.plan uses, so the test and the app share the drop-You path. Fixes the delegate doc comment: the balance check is against the paid parts. --- .../internal/groupsend/GroupSendState.kt | 12 +- .../group/GroupSendPaymentDelegate.kt | 4 +- .../shared/payments/group/GroupSendSplit.kt | 18 +++ .../group/GroupSendSplitVectorsTest.kt | 37 +++++ test-vectors/group-send-split.json | 149 ++++++++++++++++++ 5 files changed, 209 insertions(+), 11 deletions(-) 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 d94768c921..ed96d4a435 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 @@ -40,16 +40,8 @@ internal data class GroupSendState( val plan: GroupSendPlan get() { val total = total ?: return GroupSendPlan.NoRecipients - if (paid.isEmpty()) return GroupSendPlan.NoRecipients - val split = GroupSendSplit.split( - GroupSendSplit.minorUnits(total), - selected.size, - maxRecipients = GroupSendSplit.MAX_RECIPIENTS + if (includesViewer) 1 else 0, - ) - if (split !is GroupSendPlan.Pay) return split - return GroupSendPlan.Pay( - split.parts.filterIndexed { i, _ -> selected[i] != viewerId } - ) + val viewerAt = selected.indexOf(viewerId).takeIf { it >= 0 } + return GroupSendSplit.splitWithViewer(GroupSendSplit.minorUnits(total), paid.size, viewerAt) } /** Who Select People lists: the roster without the sender, who has a "You" row of their own. */ 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 e093952fce..5cb119a0d3 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 @@ -54,6 +54,24 @@ 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, who is checked at 0-based position [viewerAt] in check order + * (null when not checked), then the sender's part is dropped. The result's parts are the paid + * members', in check order. The too-small and recipient-count rules apply to the split with the + * sender in it, which may be [MAX_RECIPIENTS] + 1. + */ + fun splitWithViewer(totalUnits: Long, paid: Int, viewerAt: Int?): GroupSendPlan { + if (paid <= 0) return GroupSendPlan.NoRecipients + val split = split( + totalUnits, + paid + if (viewerAt != null) 1 else 0, + maxRecipients = MAX_RECIPIENTS + if (viewerAt != null) 1 else 0, + ) + if (split !is GroupSendPlan.Pay || viewerAt == null) return split + return GroupSendPlan.Pay(split.parts.filterIndexed { i, _ -> i != viewerAt }) + } + /** [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..7a745a1dc1 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,43 @@ class GroupSendSplitVectorsTest { } } + @Test + fun `every You vector`() { + val vectors = fixture.getJSONArray("youVectors") + assertEquals(11, 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"), v.getInt("youAt")) + + 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) + + if (v.has("eachLabel")) { + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each" + 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..4b841a6f32 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -177,5 +177,154 @@ "recipients": 0, "expectedError": "no-recipients" } + ], + "youVectors": [ + { + "name": "you and two even", + "total": "30.00", + "recipients": 2, + "youAt": 0, + "intent": "SendPublicMultiPayment", + "youPart": "10.00", + "parts": [ + "10.00", + "10.00" + ], + "paidTotal": "20.00", + "eachLabel": "$10.00 Each" + }, + { + "name": "you first takes the extra cent", + "total": "1.00", + "recipients": 2, + "youAt": 0, + "intent": "SendPublicMultiPayment", + "youPart": "0.34", + "parts": [ + "0.33", + "0.33" + ], + "paidTotal": "0.66", + "eachLabel": "$0.33 Each" + }, + { + "name": "you last, others take the extra cents", + "total": "1.00", + "recipients": 2, + "youAt": 2, + "intent": "SendPublicMultiPayment", + "youPart": "0.33", + "parts": [ + "0.34", + "0.33" + ], + "paidTotal": "0.67", + "eachLabel": "~$0.33 Each" + }, + { + "name": "you in the middle", + "total": "1.00", + "recipients": 2, + "youAt": 1, + "intent": "SendPublicMultiPayment", + "youPart": "0.33", + "parts": [ + "0.34", + "0.33" + ], + "paidTotal": "0.67", + "eachLabel": "~$0.33 Each" + }, + { + "name": "you and one", + "total": "5.01", + "recipients": 1, + "youAt": 1, + "intent": "SendPublicPayment", + "youPart": "2.50", + "parts": [ + "2.51" + ], + "paidTotal": "2.51" + }, + { + "name": "ten plus you", + "total": "11.00", + "recipients": 10, + "youAt": 0, + "intent": "SendPublicMultiPayment", + "youPart": "1.00", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00" + ], + "paidTotal": "10.00", + "eachLabel": "$1.00 Each" + }, + { + "name": "ten plus you uneven", + "total": "1.00", + "recipients": 10, + "youAt": 5, + "intent": "SendPublicMultiPayment", + "youPart": "0.09", + "parts": [ + "0.10", + "0.09", + "0.09", + "0.09", + "0.09", + "0.09", + "0.09", + "0.09", + "0.09", + "0.09" + ], + "paidTotal": "0.91", + "eachLabel": "~$0.09 Each" + }, + { + "name": "you exact minimum", + "total": "0.03", + "recipients": 2, + "youAt": 0, + "intent": "SendPublicMultiPayment", + "youPart": "0.01", + "parts": [ + "0.01", + "0.01" + ], + "paidTotal": "0.02", + "eachLabel": "$0.01 Each" + }, + { + "name": "too small with you", + "total": "0.02", + "recipients": 2, + "youAt": 0, + "expectedError": "part-below-minimum" + }, + { + "name": "only you", + "total": "1.00", + "recipients": 0, + "youAt": 0, + "expectedError": "no-recipients" + }, + { + "name": "eleven plus you", + "total": "12.00", + "recipients": 11, + "youAt": 0, + "expectedError": "too-many-recipients" + } ] } From 81a9f71fac399d48cea1d5564d6321ff9ca17a09 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:10:57 -0400 Subject: [PATCH 4/7] feat(group-send): say "including You" on the confirm sheet When the sender is in the split, the per-person line reads "$X each, including You" ("~" unless every part, the sender's included, is equal), and shows even for a single paid member. The You vectors now carry that label. --- .../src/androidMain/res/values/strings.xml | 2 ++ .../internal/screens/cash/GroupSendScreens.kt | 24 +++++++++----- .../internal/ChatGroupCashLinkTest.kt | 32 +++++++++++++++++-- .../shared/payments/group/GroupSendSplit.kt | 19 ++++++++--- .../group/GroupSendSplitVectorsTest.kt | 3 +- test-vectors/group-send-split.json | 17 +++++----- 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index ccf6f16275..48762ec863 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2013,6 +2013,8 @@ %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/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index ccb2c80743..958b397e1d 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 @@ -210,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( @@ -239,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, ) @@ -251,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 98ff12b357..5f02dda99f 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 @@ -498,7 +498,7 @@ class ChatGroupCashLinkTest { val groupSend = vm.stateFlow.value.groupSend assertEquals(GroupSendState.Stage.Confirm, groupSend?.stage) assertEquals(Fiat(30.0), groupSend?.total) - assertEquals(GroupSendPlan.Pay(listOf(1000L, 1000L)), groupSend?.plan) + assertEquals(GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L), groupSend?.plan) vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) advanceUntilIdle() @@ -508,7 +508,7 @@ class ChatGroupCashLinkTest { groupSendPayments.send( chatId = chatId, recipients = listOf(roster[0].userId, roster[1].userId), - plan = GroupSendPlan.Pay(listOf(1000L, 1000L)), + plan = GroupSendPlan.Pay(listOf(1000L, 1000L), viewerPart = 1000L), currency = CurrencyCode.USD, token = token, source = any(), @@ -522,7 +522,7 @@ class ChatGroupCashLinkTest { selectingPeople(vm, total = 1.0) vm.checkYou(); vm.checkMember(0); vm.checkMember(1) - assertEquals(GroupSendPlan.Pay(listOf(33L, 33L)), vm.stateFlow.value.groupSend?.plan) + assertEquals(GroupSendPlan.Pay(listOf(33L, 33L), viewerPart = 34L), vm.stateFlow.value.groupSend?.plan) assertEquals(listOf(roster[0].userId, roster[1].userId), vm.stateFlow.value.groupSend?.paid) } @@ -567,4 +567,30 @@ class ChatGroupCashLinkTest { 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/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 5cb119a0d3..9facc82731 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() @@ -69,7 +77,10 @@ object GroupSendSplit { maxRecipients = MAX_RECIPIENTS + if (viewerAt != null) 1 else 0, ) if (split !is GroupSendPlan.Pay || viewerAt == null) return split - return GroupSendPlan.Pay(split.parts.filterIndexed { i, _ -> i != viewerAt }) + return GroupSendPlan.Pay( + parts = split.parts.filterIndexed { i, _ -> i != viewerAt }, + viewerPart = split.parts[viewerAt], + ) } /** [fiat] in its currency's minor unit, rounded half-up to that unit. */ 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 7a745a1dc1..ff1e163e94 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 @@ -89,8 +89,9 @@ class GroupSendSplitVectorsTest { 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" + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each, including You" assertEquals(v.getString("eachLabel"), if (pay.isEven) each else "~$each", name) } } diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index 4b841a6f32..accb8620e2 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -191,7 +191,7 @@ "10.00" ], "paidTotal": "20.00", - "eachLabel": "$10.00 Each" + "eachLabel": "$10.00 Each, including You" }, { "name": "you first takes the extra cent", @@ -205,7 +205,7 @@ "0.33" ], "paidTotal": "0.66", - "eachLabel": "$0.33 Each" + "eachLabel": "~$0.33 Each, including You" }, { "name": "you last, others take the extra cents", @@ -219,7 +219,7 @@ "0.33" ], "paidTotal": "0.67", - "eachLabel": "~$0.33 Each" + "eachLabel": "~$0.33 Each, including You" }, { "name": "you in the middle", @@ -233,7 +233,7 @@ "0.33" ], "paidTotal": "0.67", - "eachLabel": "~$0.33 Each" + "eachLabel": "~$0.33 Each, including You" }, { "name": "you and one", @@ -245,7 +245,8 @@ "parts": [ "2.51" ], - "paidTotal": "2.51" + "paidTotal": "2.51", + "eachLabel": "~$2.51 Each, including You" }, { "name": "ten plus you", @@ -267,7 +268,7 @@ "1.00" ], "paidTotal": "10.00", - "eachLabel": "$1.00 Each" + "eachLabel": "$1.00 Each, including You" }, { "name": "ten plus you uneven", @@ -289,7 +290,7 @@ "0.09" ], "paidTotal": "0.91", - "eachLabel": "~$0.09 Each" + "eachLabel": "~$0.09 Each, including You" }, { "name": "you exact minimum", @@ -303,7 +304,7 @@ "0.01" ], "paidTotal": "0.02", - "eachLabel": "$0.01 Each" + "eachLabel": "$0.01 Each, including You" }, { "name": "too small with you", From b936552dd98f7874e5bf42122fe91153579deabb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:15:10 -0400 Subject: [PATCH 5/7] feat(group-send): You is the last share and counts toward the cap You's part is always the last share, so You never takes an extra cent, and the 10 People Max cap counts You: nine others plus You is the most. The Each strings are capitalized. --- .../src/androidMain/res/values/strings.xml | 8 +- .../internal/groupsend/GroupSendState.kt | 9 +-- .../internal/ChatGroupCashLinkTest.kt | 12 +-- .../shared/payments/group/GroupSendSplit.kt | 31 +++---- .../group/GroupSendSplitVectorsTest.kt | 4 +- test-vectors/group-send-split.json | 81 +++++-------------- 6 files changed, 47 insertions(+), 98 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 48762ec863..3b37ba6c2b 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2011,10 +2011,10 @@ to %1$d person to %1$d people - %1$s each - ~%1$s each - %1$s each, including You - ~%1$s each, including You + %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/internal/groupsend/GroupSendState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt index ed96d4a435..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 @@ -34,14 +34,13 @@ internal data class GroupSendState( /** * 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, in check order, then 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() { val total = total ?: return GroupSendPlan.NoRecipients - val viewerAt = selected.indexOf(viewerId).takeIf { it >= 0 } - return GroupSendSplit.splitWithViewer(GroupSendSplit.minorUnits(total), paid.size, viewerAt) + return GroupSendSplit.splitWithViewer(GroupSendSplit.minorUnits(total), paid.size, includesViewer) } /** Who Select People lists: the roster without the sender, who has a "You" row of their own. */ @@ -53,11 +52,11 @@ internal data class GroupSendState( /** * [selected] with [member] checked or unchecked, or null when checking them would make an 11th - * paid member. The sender does not count toward the cap: they are not paid. + * 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 } - member != viewerId && paid.size >= GroupSendSplit.MAX_RECIPIENTS -> null + selected.size >= GroupSendSplit.MAX_RECIPIENTS -> null else -> selected.plusElement(member) } } 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 5f02dda99f..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 @@ -517,12 +517,12 @@ class ChatGroupCashLinkTest { } @Test - fun `You checked first takes the extra cent and the others are paid the smaller parts`() = runTest(mainCoroutineRule.dispatcher) { + 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(33L, 33L), viewerPart = 34L), vm.stateFlow.value.groupSend?.plan) + 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) } @@ -541,16 +541,16 @@ class ChatGroupCashLinkTest { } @Test - fun `ten others plus You is allowed and an 11th other is not`() = runTest(mainCoroutineRule.dispatcher) { + 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(11) + vm.check(10) advanceUntilIdle() val groupSend = vm.stateFlow.value.groupSend - assertEquals(10, groupSend?.paid?.size) - assertEquals(11, groupSend?.selected?.size) + assertEquals(9, groupSend?.paid?.size) + assertEquals(10, groupSend?.selected?.size) assertTrue(groupSend?.plan is GroupSendPlan.Pay) assertEquals(1, BottomBarManager.messages.value.size) } 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 9facc82731..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 @@ -49,13 +49,9 @@ object GroupSendSplit { const val MAX_RECIPIENTS = 10 const val MINIMUM_PART_UNITS = 1L - /** - * @param maxRecipients the most the total may be split between. A split that includes the sender - * counts one more than the people paid, so it allows [MAX_RECIPIENTS] + 1. - */ - fun split(totalUnits: Long, recipients: Int, maxRecipients: Int = MAX_RECIPIENTS): GroupSendPlan { + fun split(totalUnits: Long, recipients: Int): GroupSendPlan { if (recipients <= 0) return GroupSendPlan.NoRecipients - if (recipients > maxRecipients) return GroupSendPlan.TooManyRecipients + if (recipients > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients val base = totalUnits / recipients if (base < MINIMUM_PART_UNITS) return GroupSendPlan.PartBelowMinimum val leftover = totalUnits % recipients @@ -64,23 +60,16 @@ object GroupSendSplit { /** * A split that the sender is part of but is not paid: [totalUnits] is divided between the - * [paid] members and the sender, who is checked at 0-based position [viewerAt] in check order - * (null when not checked), then the sender's part is dropped. The result's parts are the paid - * members', in check order. The too-small and recipient-count rules apply to the split with the - * sender in it, which may be [MAX_RECIPIENTS] + 1. + * [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, viewerAt: Int?): GroupSendPlan { + fun splitWithViewer(totalUnits: Long, paid: Int, includeViewer: Boolean): GroupSendPlan { if (paid <= 0) return GroupSendPlan.NoRecipients - val split = split( - totalUnits, - paid + if (viewerAt != null) 1 else 0, - maxRecipients = MAX_RECIPIENTS + if (viewerAt != null) 1 else 0, - ) - if (split !is GroupSendPlan.Pay || viewerAt == null) return split - return GroupSendPlan.Pay( - parts = split.parts.filterIndexed { i, _ -> i != viewerAt }, - viewerPart = split.parts[viewerAt], - ) + 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. */ 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 ff1e163e94..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 @@ -62,12 +62,12 @@ class GroupSendSplitVectorsTest { @Test fun `every You vector`() { val vectors = fixture.getJSONArray("youVectors") - assertEquals(11, vectors.length()) + 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"), v.getInt("youAt")) + val plan = GroupSendSplit.splitWithViewer(total, v.getInt("recipients"), includeViewer = true) if (v.has("expectedError")) { val error = when (val rule = v.getString("expectedError")) { diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index accb8620e2..3b0e8b0ce2 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -183,7 +183,6 @@ "name": "you and two even", "total": "30.00", "recipients": 2, - "youAt": 0, "intent": "SendPublicMultiPayment", "youPart": "10.00", "parts": [ @@ -194,38 +193,9 @@ "eachLabel": "$10.00 Each, including You" }, { - "name": "you first takes the extra cent", + "name": "others take the extra cents", "total": "1.00", "recipients": 2, - "youAt": 0, - "intent": "SendPublicMultiPayment", - "youPart": "0.34", - "parts": [ - "0.33", - "0.33" - ], - "paidTotal": "0.66", - "eachLabel": "~$0.33 Each, including You" - }, - { - "name": "you last, others take the extra cents", - "total": "1.00", - "recipients": 2, - "youAt": 2, - "intent": "SendPublicMultiPayment", - "youPart": "0.33", - "parts": [ - "0.34", - "0.33" - ], - "paidTotal": "0.67", - "eachLabel": "~$0.33 Each, including You" - }, - { - "name": "you in the middle", - "total": "1.00", - "recipients": 2, - "youAt": 1, "intent": "SendPublicMultiPayment", "youPart": "0.33", "parts": [ @@ -239,7 +209,6 @@ "name": "you and one", "total": "5.01", "recipients": 1, - "youAt": 1, "intent": "SendPublicPayment", "youPart": "2.50", "parts": [ @@ -249,10 +218,9 @@ "eachLabel": "~$2.51 Each, including You" }, { - "name": "ten plus you", - "total": "11.00", - "recipients": 10, - "youAt": 0, + "name": "nine plus you", + "total": "10.00", + "recipients": 9, "intent": "SendPublicMultiPayment", "youPart": "1.00", "parts": [ @@ -264,39 +232,35 @@ "1.00", "1.00", "1.00", - "1.00", "1.00" ], - "paidTotal": "10.00", + "paidTotal": "9.00", "eachLabel": "$1.00 Each, including You" }, { - "name": "ten plus you uneven", - "total": "1.00", - "recipients": 10, - "youAt": 5, + "name": "nine plus you uneven", + "total": "1.01", + "recipients": 9, "intent": "SendPublicMultiPayment", - "youPart": "0.09", + "youPart": "0.10", "parts": [ + "0.11", + "0.10", "0.10", - "0.09", - "0.09", - "0.09", - "0.09", - "0.09", - "0.09", - "0.09", - "0.09", - "0.09" + "0.10", + "0.10", + "0.10", + "0.10", + "0.10", + "0.10" ], "paidTotal": "0.91", - "eachLabel": "~$0.09 Each, including You" + "eachLabel": "~$0.10 Each, including You" }, { "name": "you exact minimum", "total": "0.03", "recipients": 2, - "youAt": 0, "intent": "SendPublicMultiPayment", "youPart": "0.01", "parts": [ @@ -310,21 +274,18 @@ "name": "too small with you", "total": "0.02", "recipients": 2, - "youAt": 0, "expectedError": "part-below-minimum" }, { "name": "only you", "total": "1.00", "recipients": 0, - "youAt": 0, "expectedError": "no-recipients" }, { - "name": "eleven plus you", - "total": "12.00", - "recipients": 11, - "youAt": 0, + "name": "ten plus you", + "total": "11.00", + "recipients": 10, "expectedError": "too-many-recipients" } ] From 64667dc00b5f403c470d84f65f61358a23c41de5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:15:27 -0400 Subject: [PATCH 6/7] docs(group-send): capitalize Each in the label doc --- .../app/messenger/internal/screens/cash/GroupSendScreens.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 958b397e1d..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 @@ -252,7 +252,7 @@ internal fun GroupSendConfirmSheet( } /** - * "$1.00 each" when every part, the sender's included, is equal, else "~$0.33 each" for the + * "$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 From e638f9021657a49f465236687f5efab196a71638 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:21:19 -0400 Subject: [PATCH 7/7] fix(group-send): keep the chat bubble's each lowercase The bubble gets its own lowercase strings; only the confirm sheet's Each is capitalized. --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 3 +++ .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 3b37ba6c2b..aa61563a7d 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2013,6 +2013,9 @@ %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 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,