diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml
index 0e00a6b5f6..d588972214 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,11 @@
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
+
+ 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..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
@@ -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,37 @@ 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. */
+ 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 }
@@ -69,15 +83,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 +142,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 = total.quarks / 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..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 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 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 e928d2aabb..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
@@ -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.smallestShare ?: 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, draft.isUnevenSplit, 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..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
@@ -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.total?.analytics, null),
)
created.id
}
@@ -298,7 +298,7 @@ internal class RequestMoneyViewModel @Inject constructor(
PaymentRequestEvents.created(
draft.requestedCount,
AnalyticsState.FAILURE,
- draft.perPerson?.analytics,
+ draft.total?.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..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
@@ -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,86 @@ 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)
+ 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`() {
+ 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..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, 15, 17 and 18. */
+/** The summary copy for the spec's cases 14–18, 49, 52 and 53. */
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class RequestCopyTest {
@@ -26,33 +26,60 @@ 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.smallestShare!!.text(), draft.isUnevenSplit, 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.smallestShare!!.text(), draft.isUnevenSplit, resources))
+ }
+
+ // 49
+ @Test
+ 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.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
+ @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.smallestShare!!.text(), draft.isUnevenSplit, 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..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,7 +81,8 @@ class RequestMoneyAnalyticsTest {
vm.requestConfirm()
}
- private val perPerson = Fiat(5.0, 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`() {
@@ -91,7 +92,7 @@ class RequestMoneyAnalyticsTest {
vm.send()
assertEquals(
- listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, perPerson.analytics, null)),
+ listOf(PaymentRequestEvents.created(2, AnalyticsState.SUCCESS, total.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, 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"
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.