From b9ebad472e88ba79bdf5f0213faa11426b3c7cbe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:14:35 -0400 Subject: [PATCH 1/4] feat(messenger): group requests take a total split between people The amount entry now asks for the total, titled Total Amount, and the summary labels it total. Select People reads Split Between in the request flow; group send keeps its title. The total is split in the currency's smallest unit: each share gets floor(T/N) and the T mod N leftover units go one each to the first non-creator shares in roster order, so the shares sum to T. Request is disabled when T < N. Each share's native_amount now comes from that split, and the button reads "Request Each". --- .../src/androidMain/res/values/strings.xml | 11 ++- .../internal/request/NewRequestDraft.kt | 51 ++++++++--- .../messenger/internal/request/RequestCopy.kt | 6 +- .../internal/request/RequestMoneyScreen.kt | 10 ++- .../internal/request/RequestMoneyViewModel.kt | 12 +-- .../internal/screens/cash/GroupSendScreens.kt | 3 +- .../internal/request/NewRequestDraftTest.kt | 90 +++++++++++++++++-- .../internal/request/RequestCopyTest.kt | 33 +++++-- .../request/RequestMoneyAnalyticsTest.kt | 7 +- .../network/extensions/LocalToProtobuf.kt | 6 +- .../models/paymentrequest/PaymentRequest.kt | 9 +- 11 files changed, 185 insertions(+), 53 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..ce0da54e1f 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1079,9 +1079,9 @@ Previous Requests %1$s paid, %2$s requested ✓ %1$s paid - Amount Per Person + Total Amount New Request - per person + total Name What is the request for? People @@ -1156,10 +1156,9 @@ Until %1$s Request automatically closes after - - Request %2$s from %1$d Person - Request %2$s from %1$d People - + + Request %1$s Each + Split Between You 100 People Max A request can include up to 100 people. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt index 25b8c357c9..ce8665a01e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal.request import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.paymentrequest.NewShare import com.flipcash.services.models.paymentrequest.NewSplitRequest import com.flipcash.services.models.paymentrequest.PaymentRequestText import com.getcode.opencode.model.core.ID @@ -14,10 +15,11 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant /** - * A payment request a creator is putting together in a private group, from Amount Per Person to - * the confirm sheet. Frames 04–06 of the group requests spec. + * A payment request a creator is putting together in a private group, from Total Amount to the + * confirm sheet. Frames 04–06 of the group requests spec. * - * @param members who Select People lists: the creator first, then the rest of the roster in its + * @param total what the shares add up to; see [splitTotal]. + * @param members who Split Between lists: the creator first, then the rest of the roster in its * order. Shares are sent in this order, whatever order the boxes were checked in. * @param selected who is asked, the creator included when [viewerId] is in it. * @param idempotencyKey minted when the confirm sheet first opens, and reused by every retry of @@ -29,25 +31,31 @@ internal data class NewRequestDraft( val viewerId: ID, val members: List, val title: String, - val perPerson: Fiat? = null, + val total: Fiat? = null, val selected: Set = if (members.size > MAX_SHARES) emptySet() else members.toSet(), val deadline: Deadline = Deadline.Week, val idempotencyKey: IdempotencyKey? = null, val createdRequestId: ID? = null, ) { - /** Members asked other than the creator: the N in "Request $20 from N People". */ + /** Members asked other than the creator. */ val requestedCount: Int get() = selected.count { it != viewerId } val includesCreator: Boolean get() = viewerId in selected - /** Select People's button: at least one member other than the creator. */ + /** Split Between's button: at least one member other than the creator. */ val canContinue: Boolean get() = requestedCount > 0 - val canSend: Boolean get() = canContinue && perPerson != null && title.isNotBlank() + val canSend: Boolean get() = canContinue && split != null && title.isNotBlank() /** Who is asked, in [members] order. */ val shares: List get() = members.filter { it in selected } + /** [total] split between [shares], or null with no total or a total too small to give everyone a unit. */ + val split: List? get() = total?.let { splitTotal(it, shares, viewerId) } + + /** The largest share: the amount in "Request $33.34 Each". */ + val largestShare: Fiat? get() = split?.maxByOrNull { it.amount.quarks }?.amount + val people: People get() { val others = members.count { it != viewerId } @@ -69,15 +77,14 @@ internal data class NewRequestDraft( /** What Create sends, with [now] the moment Send Request was tapped. */ fun toSplit(chatId: ChatId, text: PaymentRequestText.Encrypted, now: Instant): NewSplitRequest? { - val amount = perPerson ?: return null + val amount = total ?: return null if (!canContinue) return null return NewSplitRequest( chatId = chatId, text = text, currency = amount.currencyCode, endsAt = deadline.endsAt(now), - shares = shares, - perPerson = amount, + shares = split ?: return null, ) } @@ -129,3 +136,27 @@ internal data class NewRequestDraft( listOf(viewerId) + roster.map { it.userId }.filter { it != viewerId }.distinct() } } + +/** + * [total] split equally between [shares], in their order: each gets the total over the share + * count, rounded down to the currency's smallest unit, and the units left over go one each to the + * first shares that are not [creator]'s. The creator's share is marked paid from creation, so the + * leftover lands on members who actually pay. The amounts always sum to [total]. + * + * Null when there are no shares, or the total is fewer units than shares, since every share must + * be at least one unit. + */ +internal fun splitTotal(total: Fiat, shares: List, creator: ID): List? { + if (shares.isEmpty()) return null + val unit = total.smallestUnit.quarks.coerceAtLeast(1L) + val units = Math.round(total.quarks.toDouble() / unit) + val count = shares.size.toLong() + if (units < count) return null + val base = units / count + val remainder = (units % count).toInt() + val takesExtra = (shares.filter { it != creator } + shares.filter { it == creator }).take(remainder).toSet() + return shares.map { id -> + val shareUnits = if (id in takesExtra) base + 1 else base + NewShare(id, Fiat(quarks = shareUnits * unit, currencyCode = total.currencyCode)) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt index 0a0ee96c4c..8188ba6390 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt @@ -20,9 +20,9 @@ internal fun peopleValue(people: NewRequestDraft.People, resources: ResourceHelp internal fun peopleDetail(people: NewRequestDraft.People, resources: ResourceHelper): String? = resources.getString(R.string.subtitle_requestMarkedPaid).takeIf { people.markedPaid } -/** The summary's button, "Request from People", the creator not counted. */ -internal fun requestButtonText(draft: NewRequestDraft, amount: String, resources: ResourceHelper): String = - resources.getQuantityString(R.plurals.action_requestFromPeople, draft.requestedCount, draft.requestedCount, amount) +/** The summary's button, "Request Each", [share] being the largest share formatted. */ +internal fun requestButtonText(share: String, resources: ResourceHelper): String = + resources.getString(R.string.action_requestEach, share) /** The sheet for checking a 101st share: title and body. */ internal fun maxPeopleNotice(resources: ResourceHelper): Pair = diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt index e928d2aabb..d4669d4c15 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt @@ -134,7 +134,7 @@ internal fun RequestMoneyScreen( largeHeader = true, appBar = { AppBarWithTitle( - title = stringResource(R.string.title_amountPerPerson), + title = stringResource(R.string.title_totalAmount), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = back, ) @@ -166,6 +166,7 @@ internal fun RequestMoneyScreen( onBack = back, onToggle = { viewModel.toggle(it.userId) }, onSend = back, + title = stringResource(R.string.title_splitBetween), buttonText = stringResource(R.string.action_done), buttonEnabled = draft.canContinue, viewerId = viewModel.viewerId, @@ -371,7 +372,8 @@ private fun RequestSummaryContent( onTiming: () -> Unit, onRequest: () -> Unit, ) { - val amount = draft.perPerson?.text().orEmpty() + val amount = draft.total?.text().orEmpty() + val share = (draft.largestShare ?: draft.total)?.text().orEmpty() val resources = LocalResources.current CodeScaffold( topBar = { @@ -389,7 +391,7 @@ private fun RequestSummaryContent( .padding(horizontal = CodeTheme.dimens.inset) .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("request_summary_send"), - text = requestButtonText(draft, amount, resources), + text = requestButtonText(share, resources), enabled = draft.canSend, isLoading = sending, onClick = onRequest, @@ -409,7 +411,7 @@ private fun RequestSummaryContent( color = CodeTheme.colors.textMain, ) Text( - text = stringResource(R.string.label_perPerson), + text = stringResource(R.string.label_requestTotal), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt index 11e269d833..5190b2b713 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt @@ -161,20 +161,20 @@ internal class RequestMoneyViewModel @Inject constructor( _state.update { it.copy(stage = Stage.Amount, draft = null) } } - /** Amount Per Person's Next. Zero stays put. */ + /** Total Amount's Next. Zero stays put. */ fun amountEntered() { val entered = amountDelegate.state.value.enteredAmount if (entered <= 0.0) return - val perPerson = Fiat(entered, currency) + val total = Fiat(entered, currency) val viewerId = userManager.accountId ?: return val chatId = _state.value.chatId ?: return val roster = rosters.state.value[chatId].orEmpty() _state.update { s -> - val draft = s.draft?.copy(perPerson = perPerson) ?: NewRequestDraft( + val draft = s.draft?.copy(total = total) ?: NewRequestDraft( viewerId = viewerId, members = NewRequestDraft.members(viewerId, roster), title = defaultTitle(), - perPerson = perPerson, + total = total, ) s.copy(stage = Stage.Summary, draft = draft) } @@ -262,7 +262,7 @@ internal class RequestMoneyViewModel @Inject constructor( val created = requests.create(split, key).getOrElse { return@launch failed(it, chatId, draft) } // Once per confirm: a retry reuses createdRequestId and never reaches here. analytics.track( - PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.perPerson?.analytics, null), + PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.largestShare?.analytics, null), ) created.id } @@ -298,7 +298,7 @@ internal class RequestMoneyViewModel @Inject constructor( PaymentRequestEvents.created( draft.requestedCount, AnalyticsState.FAILURE, - draft.perPerson?.analytics, + draft.largestShare?.analytics, error.analytics, ), ) 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/request/NewRequestDraftTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt index 161206b6cf..f78ac023b2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt @@ -4,6 +4,7 @@ import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline import com.flipcash.app.messenger.internal.request.NewRequestDraft.People import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.paymentrequest.NewShare import com.flipcash.services.models.paymentrequest.PaymentRequestText import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID @@ -23,12 +24,15 @@ import kotlin.time.Instant class NewRequestDraftTest { private val viewer = id(0) - private val twenty = Fiat(20.0, CurrencyCode.USD) + private val twenty = usd(2_000) private val now = Instant.fromEpochSeconds(1_800_000_000) private fun id(n: Int): ID = listOf((n shr 8).toByte(), n.toByte()) - private fun draft(memberCount: Int, rosterViewerAt: Int = 3) = run { + /** [cents] US cents, built from quarks so no double rounding creeps in. */ + private fun usd(cents: Long) = Fiat(quarks = cents * 10_000, currencyCode = CurrencyCode.USD) + + private fun draft(memberCount: Int, rosterViewerAt: Int = 3, total: Fiat = usd(52_000)) = run { // The viewer sits mid-roster to show the creator still goes first. val others = (1 until memberCount).map(::id) val roster = others.toMutableList().apply { add(rosterViewerAt.coerceAtMost(size), viewer) } @@ -37,13 +41,13 @@ class NewRequestDraftTest { viewerId = viewer, members = NewRequestDraft.members(viewer, roster), title = "Saturday Request", - perPerson = twenty, + total = total, ) } // 14 @Test - fun `everyone selected asks 25 and sends 26 shares creator first`() { + fun `520 between everyone asks 25 and sends 26 shares of 20 creator first`() { val draft = draft(26) assertEquals(People.EveryoneIncludingYou, draft.people) @@ -52,16 +56,21 @@ class NewRequestDraftTest { assertEquals(26, draft.shares.size) assertEquals(viewer, draft.shares.first()) assertEquals((1 until 26).map(::id), draft.shares.drop(1)) + assertEquals(draft.shares, draft.split!!.map { it.userId }) + assertEquals(List(26) { twenty }, draft.split!!.map { it.amount }) + assertEquals(twenty, draft.largestShare) } // 15 @Test - fun `creator and one other reads one person including you`() { - val draft = draft(26).copy(selected = setOf(viewer, id(4))) + fun `40 between the creator and one other reads one person including you`() { + val draft = draft(26, total = usd(4_000)).copy(selected = setOf(viewer, id(4))) assertEquals(People.SomeIncludingYou(1), draft.people) assertEquals(1, draft.requestedCount) assertEquals(listOf(viewer, id(4)), draft.shares) + assertEquals(listOf(twenty, twenty), draft.split!!.map { it.amount }) + assertEquals(twenty, draft.largestShare) } // 16 @@ -116,7 +125,74 @@ class NewRequestDraftTest { assertEquals(now + 28.days - 5.minutes, split.endsAt) assertEquals(CurrencyCode.USD, split.currency) - assertEquals(twenty, split.perPerson) + assertEquals(draft.split, split.shares) + } + + // 49 + @Test + fun `100 between the creator and two others puts the extra cent on the first other`() { + val draft = draft(5, total = usd(10_000)).copy(selected = setOf(viewer, id(1), id(2))) + + assertEquals(listOf(viewer, id(1), id(2)), draft.split!!.map { it.userId }) + assertEquals(listOf(usd(3_333), usd(3_334), usd(3_333)), draft.split!!.map { it.amount }) + assertEquals(usd(3_334), draft.largestShare) + assertTrue(draft.canSend) + } + + // 50 + @Test + fun `100_02 between three others without the creator is three even shares`() { + val draft = draft(5, total = usd(10_002)).copy(selected = setOf(id(1), id(2), id(3))) + + val amounts = draft.split!!.map { it.amount } + assertEquals(listOf(usd(3_334), usd(3_334), usd(3_334)), amounts) + assertEquals(10_002L * 10_000, amounts.sumOf { it.quarks }) + } + + // 51 + @Test + fun `2 cents between three people cannot be sent`() { + val draft = draft(5, total = usd(2)).copy(selected = setOf(viewer, id(1), id(2))) + + assertNull(draft.split) + assertFalse(draft.canSend) + assertNull(draft.toSplit(ChatId(byteArrayOf(1)), sealed(), now)) + } + + // 52 + @Test + fun `40 to one other without the creator is a single share of 40`() { + val draft = draft(5, total = usd(4_000)).copy(selected = setOf(id(2))) + + assertEquals(listOf(NewShare(id(2), usd(4_000))), draft.split) + assertEquals(usd(4_000), draft.largestShare) + } + + @Test + fun `the remainder skips the creator wherever the creator sits`() { + // 5 cents over 3 shares leaves 2 over: both go to the others, never the creator. + val shares = listOf(id(1), viewer, id(2)) + + val split = splitTotal(usd(5), shares, viewer)!! + + assertEquals(listOf(usd(2), usd(1), usd(2)), split.map { it.amount }) + } + + @Test + fun `a total typed as a double still splits to the cent`() { + // 100.02 * 1e6 lands just under a whole quark. + val split = splitTotal(Fiat(100.02, CurrencyCode.USD), listOf(id(1), id(2), id(3)), viewer)!! + + assertEquals(List(3) { usd(3_334) }, split.map { it.amount }) + } + + @Test + fun `a currency with no minor unit splits in whole units`() { + val yen = Fiat(quarks = 1_000L * 1_000_000, currencyCode = CurrencyCode.JPY) + + val split = splitTotal(yen, listOf(viewer, id(1), id(2)), viewer)!! + + assertEquals(listOf(333L, 334L, 333L), split.map { it.amount.quarks / 1_000_000 }) } @Test diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt index 2a6d1622d7..ed2d7521be 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull -/** The summary copy for the spec's cases 14, 15, 17 and 18. */ +/** The summary copy for the spec's cases 14–18, 49 and 52. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class RequestCopyTest { @@ -26,33 +26,52 @@ class RequestCopyTest { private fun id(n: Int): ID = listOf((n shr 8).toByte(), n.toByte()) - private fun draft(memberCount: Int) = NewRequestDraft( + // As the summary formats an amount: "$20" for a whole amount, "$33.34" otherwise. + private fun Fiat.text(): String = formatted(Fiat.FormattingRule.Truncated) + + private fun draft(memberCount: Int, total: Fiat = Fiat(520.0, CurrencyCode.USD)) = NewRequestDraft( viewerId = viewer, members = NewRequestDraft.members( viewer, (0 until memberCount).map { ChatMember(id(it), mockk(relaxed = true), emptyList()) }, ), title = "Saturday Request", - perPerson = Fiat(20.0, CurrencyCode.USD), + total = total, ) // 14 @Test - fun `everyone selected reads everyone including you and requests from 25 people`() { + fun `520 between everyone reads everyone including you and requests 20 each`() { val draft = draft(26) assertEquals("Everyone, Including You", peopleValue(draft.people, resources)) assertEquals("You'll be marked as paid", peopleDetail(draft.people, resources)) - assertEquals("Request $20 from 25 People", requestButtonText(draft, "$20", resources)) + assertEquals("Request $20 Each", requestButtonText(draft.largestShare!!.text(), resources)) } // 15 @Test fun `creator and one other read in the singular`() { - val draft = draft(26).copy(selected = setOf(viewer, id(4))) + val draft = draft(26, total = Fiat(40.0, CurrencyCode.USD)).copy(selected = setOf(viewer, id(4))) assertEquals("1 Person, Including You", peopleValue(draft.people, resources)) - assertEquals("Request $20 from 1 Person", requestButtonText(draft, "$20", resources)) + assertEquals("Request $20 Each", requestButtonText(draft.largestShare!!.text(), resources)) + } + + // 49 + @Test + fun `100 between three requests the largest share each`() { + val draft = draft(26, total = Fiat(100.0, CurrencyCode.USD)).copy(selected = setOf(viewer, id(1), id(2))) + + assertEquals("Request $33.34 Each", requestButtonText(draft.largestShare!!.text(), resources)) + } + + // 52 + @Test + fun `40 to one other requests 40 each`() { + val draft = draft(26, total = Fiat(40.0, CurrencyCode.USD)).copy(selected = setOf(id(2))) + + assertEquals("Request $40 Each", requestButtonText(draft.largestShare!!.text(), resources)) } // 16 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt index cf7b864229..6332e6eaaf 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt @@ -81,7 +81,8 @@ class RequestMoneyAnalyticsTest { vm.requestConfirm() } - private val perPerson = Fiat(5.0, CurrencyCode.USD) + // $5 between the creator and two others: the largest share is $1.67. + private val largestShare = Fiat(quarks = 1_670_000L, currencyCode = CurrencyCode.USD) @Test fun `a created request sends Created once, counting the people asked and not the creator`() { @@ -91,7 +92,7 @@ class RequestMoneyAnalyticsTest { vm.send() assertEquals( - listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, perPerson.analytics, null)), + listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, largestShare.analytics, null)), analytics.events, ) } @@ -104,7 +105,7 @@ class RequestMoneyAnalyticsTest { vm.send() assertEquals( - listOf(PaymentRequestEvents.created(2, AnalyticsState.FAILURE, perPerson.analytics, "denied")), + listOf(PaymentRequestEvents.created(2, AnalyticsState.FAILURE, largestShare.analytics, "denied")), analytics.events, ) } diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 6a20150836..a9f017045f 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -453,10 +453,10 @@ internal fun NewSplitRequest.asGroupChatPaymentRequest(): PaymentRequestModel.Gr .setCurrency(currency.name.lowercase()) .setEndsAt(endsAt.asTimestamp()) .addAllShares( - shares.map { userId -> + shares.map { share -> PaymentRequestModel.Share.newBuilder() - .setUserId(userId.asUserId()) - .setNativeAmount(perPerson.decimalValue) + .setUserId(share.userId.asUserId()) + .setNativeAmount(share.amount.decimalValue) .build() } ) diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt index 3bc9454ffa..6bc56389a0 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt @@ -141,17 +141,20 @@ fun newerOf(held: PaymentRequest?, incoming: PaymentRequest): PaymentRequest = w /** * What a creator sends to open a split in a private group. [text] is already sealed under the - * group's chat key; [shares] lists who is asked, in roster order, the creator first when included. + * group's chat key; [shares] lists who is asked and for how much, in roster order, the creator + * first when included. */ data class NewSplitRequest( val chatId: ChatId, val text: PaymentRequestText.Encrypted, val currency: CurrencyCode, val endsAt: Instant, - val shares: List, - val perPerson: Fiat, + val shares: List, ) +/** One member's part of a [NewSplitRequest]: a whole number, at least one, of the currency's smallest unit. */ +data class NewShare(val userId: ID, val amount: Fiat) + /** * The intent id a viewer's share payment must use. Anyone who learns it before the share is paid * can use it up, so it never prints: keep it in memory for the one payment and never log it. From a5e5626ff572bd2745a03a2743dcbfa56d51884d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:36:59 -0400 Subject: [PATCH 2/4] fix(messenger): split the total with integer division --- .../flipcash/app/messenger/internal/request/NewRequestDraft.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt index ce8665a01e..5e33b04791 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt @@ -149,7 +149,7 @@ internal data class NewRequestDraft( internal fun splitTotal(total: Fiat, shares: List, creator: ID): List? { if (shares.isEmpty()) return null val unit = total.smallestUnit.quarks.coerceAtLeast(1L) - val units = Math.round(total.quarks.toDouble() / unit) + val units = total.quarks / unit val count = shares.size.toLong() if (units < count) return null val base = units / count From faae37fca70b9188f28cf67b4cf8428df1d4ac6c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:18:02 -0400 Subject: [PATCH 3/4] fix(messenger): mark an uneven split with ~ and capitalize Total When the total does not divide evenly, the summary button shows the smaller share with a tilde: $100 split three ways reads "Request ~$33.33 Each" instead of "Request $33.34 Each". An even split keeps the exact share. The label under the amount reads "Total". --- .../src/androidMain/res/values/strings.xml | 6 ++++-- .../internal/request/NewRequestDraft.kt | 8 +++++++- .../messenger/internal/request/RequestCopy.kt | 9 ++++++--- .../internal/request/RequestMoneyScreen.kt | 4 ++-- .../internal/request/NewRequestDraftTest.kt | 12 +++++++++++ .../internal/request/RequestCopyTest.kt | 20 +++++++++++++------ 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index ce0da54e1f..d588972214 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1081,7 +1081,7 @@ ✓ %1$s paid Total Amount New Request - total + Total Name What is the request for? People @@ -1156,8 +1156,10 @@ Until %1$s Request automatically closes after - + Request %1$s Each + + Request ~%1$s Each Split Between You 100 People Max diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt index 5e33b04791..52c2bd6c4c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt @@ -53,9 +53,15 @@ internal data class NewRequestDraft( /** [total] split between [shares], or null with no total or a total too small to give everyone a unit. */ val split: List? get() = total?.let { splitTotal(it, shares, viewerId) } - /** The largest share: the amount in "Request $33.34 Each". */ + /** The largest share. */ val largestShare: Fiat? get() = split?.maxByOrNull { it.amount.quarks }?.amount + /** The smallest share: the amount in "Request ~$33.33 Each". */ + val smallestShare: Fiat? get() = split?.minByOrNull { it.amount.quarks }?.amount + + /** Some shares are a unit more than others, so the button's amount is shown with "~". */ + val isUnevenSplit: Boolean get() = largestShare != smallestShare + val people: People get() { val others = members.count { it != viewerId } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt index 8188ba6390..9df1781ca4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt @@ -20,9 +20,12 @@ internal fun peopleValue(people: NewRequestDraft.People, resources: ResourceHelp internal fun peopleDetail(people: NewRequestDraft.People, resources: ResourceHelper): String? = resources.getString(R.string.subtitle_requestMarkedPaid).takeIf { people.markedPaid } -/** The summary's button, "Request Each", [share] being the largest share formatted. */ -internal fun requestButtonText(share: String, resources: ResourceHelper): String = - resources.getString(R.string.action_requestEach, share) +/** + * The summary's button, "Request Each", [share] being the smallest share formatted. When + * [uneven], some shares are a unit more, so it reads "Request ~ Each". + */ +internal fun requestButtonText(share: String, uneven: Boolean, resources: ResourceHelper): String = + resources.getString(if (uneven) R.string.action_requestEachApprox else R.string.action_requestEach, share) /** The sheet for checking a 101st share: title and body. */ internal fun maxPeopleNotice(resources: ResourceHelper): Pair = diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt index d4669d4c15..71008995a7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt @@ -373,7 +373,7 @@ private fun RequestSummaryContent( onRequest: () -> Unit, ) { val amount = draft.total?.text().orEmpty() - val share = (draft.largestShare ?: draft.total)?.text().orEmpty() + val share = (draft.smallestShare ?: draft.total)?.text().orEmpty() val resources = LocalResources.current CodeScaffold( topBar = { @@ -391,7 +391,7 @@ private fun RequestSummaryContent( .padding(horizontal = CodeTheme.dimens.inset) .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("request_summary_send"), - text = requestButtonText(share, resources), + text = requestButtonText(share, draft.isUnevenSplit, resources), enabled = draft.canSend, isLoading = sending, onClick = onRequest, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt index f78ac023b2..51ca28ea6a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt @@ -136,9 +136,21 @@ class NewRequestDraftTest { assertEquals(listOf(viewer, id(1), id(2)), draft.split!!.map { it.userId }) assertEquals(listOf(usd(3_333), usd(3_334), usd(3_333)), draft.split!!.map { it.amount }) assertEquals(usd(3_334), draft.largestShare) + assertEquals(usd(3_333), draft.smallestShare) + assertTrue(draft.isUnevenSplit) assertTrue(draft.canSend) } + // 53 + @Test + fun `100_02 between the creator and two others is three even shares`() { + val draft = draft(5, total = usd(10_002)).copy(selected = setOf(viewer, id(1), id(2))) + + assertEquals(listOf(usd(3_334), usd(3_334), usd(3_334)), draft.split!!.map { it.amount }) + assertEquals(usd(3_334), draft.smallestShare) + assertFalse(draft.isUnevenSplit) + } + // 50 @Test fun `100_02 between three others without the creator is three even shares`() { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt index ed2d7521be..c96965aae5 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull -/** The summary copy for the spec's cases 14–18, 49 and 52. */ +/** The summary copy for the spec's cases 14–18, 49, 52 and 53. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class RequestCopyTest { @@ -46,7 +46,7 @@ class RequestCopyTest { assertEquals("Everyone, Including You", peopleValue(draft.people, resources)) assertEquals("You'll be marked as paid", peopleDetail(draft.people, resources)) - assertEquals("Request $20 Each", requestButtonText(draft.largestShare!!.text(), resources)) + assertEquals("Request $20 Each", requestButtonText(draft.smallestShare!!.text(), draft.isUnevenSplit, resources)) } // 15 @@ -55,15 +55,23 @@ class RequestCopyTest { val draft = draft(26, total = Fiat(40.0, CurrencyCode.USD)).copy(selected = setOf(viewer, id(4))) assertEquals("1 Person, Including You", peopleValue(draft.people, resources)) - assertEquals("Request $20 Each", requestButtonText(draft.largestShare!!.text(), resources)) + assertEquals("Request $20 Each", requestButtonText(draft.smallestShare!!.text(), draft.isUnevenSplit, resources)) } // 49 @Test - fun `100 between three requests the largest share each`() { + fun `100 between three requests about the smaller share each`() { val draft = draft(26, total = Fiat(100.0, CurrencyCode.USD)).copy(selected = setOf(viewer, id(1), id(2))) - assertEquals("Request $33.34 Each", requestButtonText(draft.largestShare!!.text(), resources)) + assertEquals("Request ~$33.33 Each", requestButtonText(draft.smallestShare!!.text(), draft.isUnevenSplit, resources)) + } + + // 53 + @Test + fun `100_02 between three splits evenly and has no tilde`() { + val draft = draft(26, total = Fiat(100.02, CurrencyCode.USD)).copy(selected = setOf(viewer, id(1), id(2))) + + assertEquals("Request $33.34 Each", requestButtonText(draft.smallestShare!!.text(), draft.isUnevenSplit, resources)) } // 52 @@ -71,7 +79,7 @@ class RequestCopyTest { fun `40 to one other requests 40 each`() { val draft = draft(26, total = Fiat(40.0, CurrencyCode.USD)).copy(selected = setOf(id(2))) - assertEquals("Request $40 Each", requestButtonText(draft.largestShare!!.text(), resources)) + assertEquals("Request $40 Each", requestButtonText(draft.smallestShare!!.text(), draft.isUnevenSplit, resources)) } // 16 From 17051e52d3b40332d3dd5c181f17d8672590be01 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:32:52 -0400 Subject: [PATCH 4/4] fix(messenger): report the request total in Payment Request: Created Created's amount was the largest share. It is now the total the creator typed, which is what iOS sends (Analytics.paymentRequestCreated in PaymentRequestCreateViewModel.swift). The catalogue description and EVENTS.md say so. --- .../messenger/internal/request/RequestMoneyViewModel.kt | 4 ++-- .../internal/request/RequestMoneyAnalyticsTest.kt | 8 ++++---- libs/analytics-events/EVENTS.md | 2 +- libs/analytics-events/events.toml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt index 5190b2b713..6a9fcad6a5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt @@ -262,7 +262,7 @@ internal class RequestMoneyViewModel @Inject constructor( val created = requests.create(split, key).getOrElse { return@launch failed(it, chatId, draft) } // Once per confirm: a retry reuses createdRequestId and never reaches here. analytics.track( - PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.largestShare?.analytics, null), + PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.total?.analytics, null), ) created.id } @@ -298,7 +298,7 @@ internal class RequestMoneyViewModel @Inject constructor( PaymentRequestEvents.created( draft.requestedCount, AnalyticsState.FAILURE, - draft.largestShare?.analytics, + draft.total?.analytics, error.analytics, ), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt index 6332e6eaaf..675ea4d2d1 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyAnalyticsTest.kt @@ -81,8 +81,8 @@ class RequestMoneyAnalyticsTest { vm.requestConfirm() } - // $5 between the creator and two others: the largest share is $1.67. - private val largestShare = Fiat(quarks = 1_670_000L, currencyCode = CurrencyCode.USD) + // $5 between the creator and two others: Created reports the $5 total, not a $1.67 share. + private val total = Fiat(quarks = 5_000_000L, currencyCode = CurrencyCode.USD) @Test fun `a created request sends Created once, counting the people asked and not the creator`() { @@ -92,7 +92,7 @@ class RequestMoneyAnalyticsTest { vm.send() assertEquals( - listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, largestShare.analytics, null)), + listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, total.analytics, null)), analytics.events, ) } @@ -105,7 +105,7 @@ class RequestMoneyAnalyticsTest { vm.send() assertEquals( - listOf(PaymentRequestEvents.created(2, AnalyticsState.FAILURE, largestShare.analytics, "denied")), + listOf(PaymentRequestEvents.created(2, AnalyticsState.FAILURE, total.analytics, "denied")), analytics.events, ) } diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 45a75af7a7..392b0f7a2f 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -340,7 +340,7 @@ Group payment requests: one amount asked of several members of a group chat. Pay ### Payment Request: Created -A group payment request was created, on success or failure. The amount is what each person is asked for. `Payer Count` is the number of people asked, not counting the creator. +A group payment request was created, on success or failure. The amount is the request's total, split between everyone in it, the creator included when they are. `Payer Count` is the number of people asked, not counting the creator. | Property | Type | Optional | Values | |---|---|---|---| diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 223e9d0c66..1d27e1b304 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -622,7 +622,7 @@ properties = [ { name = "Payer Count", type = "count" }, { group = "outcome" }, ] -description = "A group payment request was created, on success or failure. The amount is what each person is asked for. `Payer Count` is the number of people asked, not counting the creator." +description = "A group payment request was created, on success or failure. The amount is the request's total, split between everyone in it, the creator included when they are. `Payer Count` is the number of people asked, not counting the creator." [[event]] domain = "Payment Request"