Skip to content

feat(messenger): request money from a private group - #1851

Merged
bmc08gt merged 48 commits into
code/cashfrom
feat/group-requests
Oct 26, 2026
Merged

bmc08gt merged 48 commits into
code/cashfrom
feat/group-requests

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Adds group payment requests (splits) to private group chats, following the spec in code-payments/flipcash-client-orchestrator#63. It builds on #1846 for Select People, so this PR targets feat/group-send.

Cipher. The request title and description are encrypted with the group key in :kmp:shared-core's DefaultChatCipher (c572f6e), so iOS uses the same code. It passes all 4 encrypt/decrypt vectors and all 5 must-fail cases in payment_request_text.json.

Model and store. PaymentRequest/Split/Share replace the raw protos in the service, repository and controller. PaymentRequests in shared/chat merges by version, with EXPIRED beating OPEN at equal versions. It loads OPEN/100 and RESOLVED/20 per chat, pages history, and keeps a missing marker so a DENIED/NOT_FOUND card stops refetching. Logout clears it.

Paying. directTransfer takes an optional intent id. PaymentRequestPaymentDelegate pays a share under the server's payment_intent_id and retries under that same id. The id stays in memory and is never logged.

Chat. The payment request widget gets decoded, sealed inside EncryptedContent, stored in Room and previewed in the chat list ("X requested $20", using the viewer's share). Sending it goes through the existing pending-message path, so a failed send retries the message, not Create.

Create. In a private group the cash button opens a Money sheet with Send Money and Request Money. Request Money goes history → amount per person → summary (people, deadline, name) → confirm. Shares follow roster order with the creator first. Groups over 100 members start with no one selected. The deadline is resolved when Send Request is tapped, and the idempotency key is generated once per confirm sheet.

Viewing. The request card shows in the transcript. The app bar shows a deck: swipe down cycles it, there's a "Next request" accessibility action, and tapping expands it into the list with a shared-element transition. Request details covers paying your share, plus close and mark-paid for the creator. Requests refresh every 45 s while the chat is visible and on new widget or cash messages. Ended requests stay greyed for 24 h.

Editing a request is not exposed; the server doesn't support it yet.

Spec cases 1–20 are covered by unit tests in shared/chat, shared/payments and features/messenger; case 21 (the vectors) is in :kmp:shared-core.

@bmc08gt bmc08gt self-assigned this Oct 25, 2026
@github-actions github-actions Bot added area: payments Payments, transfers, intents, billing type: feature New functionality area: crypto Solana, keys, encryption, signing area: network gRPC, connectivity, API, exchange rates area: intents Intent construction, submission, server-side state labels Oct 25, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-requests branch from 0aa7c48 to 26e1b0e Compare October 25, 2026 23:06
@bmc08gt
bmc08gt added this pull request to stack #1856 October 26, 2026 01:34
Base automatically changed from feat/group-send to code/cash October 26, 2026 01:34
* feat(payments): split a group send's total between its recipients

Splits in the currency's minor unit, rounding each part down and giving
the leftover one unit each to the first recipients in selection order.
GroupSendSplitVectorsTest runs the shared test-vectors/group-send-split.json.

* feat(payments): pay a group send's parts in one intent

GroupSendPaymentDelegate prices each part from its own fiat amount at
one verified state and pays them with GroupPayment metadata: one
recipient as a single transfer, more as one multi-transfer in selection
order. The pricing and paying move out of ReactionBoostPaymentDelegate
into SharedStatePayment so both use the same path.

* docs(test-vectors): list group-send-split.json

* feat(messenger): pay checked members from a private group's amount sheet

A private group's amount sheet now leads to Select People and a confirm
step instead of posting a cash link. Checked members are paid in the order
they were checked, an 11th check shows the 10 People Max card, and a total
under one minor unit per person shows an alert and stays on Select People.
Public groups still post a cash link.

* feat(messenger): add Select People and the group send confirm sheet

* feat(chat): draw group sends as one bubble with a recipients sheet

A cash message with multi_receiver shows who sent the total, the per-person
amount ("~" on the smallest part when the split is uneven), how many people
it went to, and the viewer's own part when they were paid. Tapping it opens
a sheet listing the sender and recipients by name.

* fix(chat): credit and label only the viewer's own part of a group send

The eager balance update credited every non-sender with the full amount of
any cash message, so a group send would have credited each member the
total. It now credits the viewer's multi_receiver part, or the amount when
they are the single receiver, and nothing when the message names others.

The cash bubble said "You received" to every non-sender. A one-person group
send names its recipient, so other members now read "<sender> sent".

* fix(messenger): let the group send confirm sheet change the token

The token pill on the confirm sheet drew a chevron but ignored taps. It now
opens the token picker, and the send re-checks the balance, since the token
can change after the amount step checked it.

* fix(chat): loosen the group send bubble and details sheet

The details sheet stacked its rows 16dp apart with 24dp avatars, against
the design's 20dp rows and 32dp avatars, and had no top gap above Sent.
The group bubble now ends on a "to Alex Morgan and 2 Others" line, as on
iOS, and a group payment to one other member uses the same layout.

* fix(chat): show the group send token name at full opacity

* fix(messenger): match the confirm sheet token pill to other cash sheets

Use the White10 pill background and padding from the reaction boost sheet, and drop the Each line when only one person is paid.

* fix(chat): size the group send token row like the single send bubble

Use the single send bubble's token icon size and lowercase "each" in the per-person line.

* fix(chat): drop the group send "You received" line, name the viewer first

The per-person line already gives the viewer's share. When the viewer was paid, the footer reads "to You and N Others", matching iOS.

* fix(chat): trim the group send bubble's vertical padding

* fix(chat): show the viewer's avatar on the group send details sheet

* feat(messenger): open a recipient's profile from the group send details

Each To row now shows a chevron and opens that member's profile. The
push waits for the sheet's hide animation, since the sheet pops whatever
is on top once it settles.

* fix(messenger): show a group payment to you alone as the group card

* fix(messenger): list the payee of a one-recipient group payment in its details

* feat(messenger): pick the send currency from a half sheet in the chat send flow

Both the amount step's token pill and the group send confirm pill open
ChatStep.SendCurrency, which reuses the paid-reaction currency list, so
choosing a currency returns to the same step instead of leaving the flow.

* fix(messenger): open the sender and token from group send details

The From row and the Sent token row had no tap target, so an incoming
payment gave no way to reach the sender's profile. Both rows now show a
chevron: From opens the sender's profile, Sent opens the token info.

* fix(messenger): slide Select People in on the amount sheet's own nav host

The amount sheet swapped its content for Select People in place, so the
screen appeared without the forward slide. The sheet now hosts its own
FlowHost with Amount and People steps, driven by the view model's group
send stage, so Next and back animate like any other push and pop.

* fix(messenger): present group send like the share sheet

Group send opens as GroupSendAmountEntry, a FullscreenSheet like ShareMessage,
and its steps are NonDraggableRoute, so the amount and Select People screens
close by their X or back arrow only. The confirm sheet gets an X, no drag
handle, and turns off drag and tap-outside dismissal.

* fix(messenger): drop the confirm sheet's X to match iOS

The group send confirm closes by system back, which returns to Select People,
as iOS's back gesture does. Drag and tap-outside dismissal stay off for the
whole flow, and the amount step keeps its X. The X row gave the avatar its
top spacing, so the content now pads its own top.
… a version-merged store

PaymentRequestController and its repository now return a PaymentRequest
domain model instead of the generated paymentrequest.v1 types, and create
takes a NewSplitRequest plus a 16-byte idempotency key. payment_intent_id
travels only in PaymentRequestDetails, as a PaymentIntentId whose toString
is redacted.

PaymentRequests (shared/chat) opens a private group's request text once
through GroupContentCrypto and keeps the greater version per request,
EXPIRED over OPEN at an equal version. StatusLine and deckOrder carry the
spec's time-left bands and deck order; unit tests cover the spec's cases
1-13.
directTransfer and IntentTransfer.create take an optional intentId; without
one they mint a fresh id as before. Submitting twice under the same id lands
once, so a retry or a second tap cannot pay a share twice.

PaymentRequestPaymentDelegate.prepare refetches the request and returns
Ready only for an open request with an unpaid share and an issued
payment_intent_id; pay sends that share to the creator in the request's
currency with requested-payment app metadata, then refetches. The id stays
in memory inside ShareToPay.Ready, whose toString leaves it out.
…orage and preview

A payment request widget now decodes to WidgetContent.PaymentRequest instead of
unsupported, encodes for sending, and survives the message row. The chat list
previews it as "<name> requested <share>" from the request store; one the
device doesn't hold yet previews as nothing. The bubble still draws as
unsupported until the request card lands.
…t message

A failed send leaves the pending row that the transcript already retries,
so a created request is posted again without creating it twice.
…hares

Shares go in roster order with the creator first, a group over 100 members
starts with nobody selected, and a preset deadline resolves when Send is
tapped. The request's idempotency key now uses the chat IdempotencyKey type.
…up's cash button

Request Money skips the balance gate, since asking for money spends nothing.
The Money sheet reuses Add Money's card actions, now public in :shared:funding.
Request Money opens on the group's request history; New Request walks
amount per person, a summary with name, people and time-to-pay editors,
and a confirm sheet. Send Request creates the request once per confirm,
under one idempotency key, then posts the request widget as a pending
chat message, so a failed send retries the message and never re-creates
the request. Select People takes its button text and shows the viewer as
"You" so the request flow reuses it.

The Money sheet change in f8a3308ec stopped four group-send tests from
reaching Select People; they now pick Send Money from the sheet.
The card resolves from the store through a pure requestCardState: a request from another
chat or creator draws as unsupported, and a DENIED or NOT_FOUND fetch marks the id missing
so the card stops refetching.
The amount keypad had no currency symbol until the user typed, the
expanded deck and pill cards let the transcript show through, and the
summary and details screens used divider rows where the design has cards.
- The Money sheet has two rows, Send Money and Request Money, with no
  title or Dismiss row.
- Create and details-action failures that have no specific copy show
  the app's network error instead of a new generic string.
- Send is hidden on request details when the fetch carried no payment
  intent id for the viewer (case 20).
- A request's widget message is retried twice on the same pending row,
  then shows the chat's Message Not Sent error with a retry. Create is
  never called again.
- Request copy uses ' rather than ’, as the spec does.
- Tests cover cases 11 and 12 through the store merge, the summary copy
  for 14 to 18, and case 22's "24 hours left to pay".
The pay delegate holds the server's payment_intent_id in LiveSecrets
before it submits, so the SubmitIntent body and trace show only its last
6 characters. A failed payment logs that suffix and keeps the id held for
the retry. A successful one releases the id and logs it in full.
Request Money swapped its content in place between history, amount,
summary and the editors, so none of them slid in. Each stage is now a
step on the screen's own FlowHost, the way group send hosts Select
People since dc3692f. The view model's stage still decides which step
is on top, and back goes through the view model so the stack follows
the stage. The deadline and confirm sheets stay drawn over Summary.
…ets with a close

Both now open the way the share sheet does: a fullscreen sheet whose root
shows a close X. Drag-to-dismiss is off on both, so the close is the only
way out and a half-made request can't be swiped away.
… the share sheet does

Both are sheets now, so the history root's back arrow becomes the share
sheet's close at the end of the app bar. Request details keeps its •••
beside the close.
…ce, push details from history

Expanded deck: tapping the scrim collapses it. Title, ring, amounts and status are each their own shared element between the pill and the first expanded card. Request details' options use the haze circle button. Deadline date and time pickers take the app's colors and type. A history row pushes Request details on Request Money's own host.
The request's Name step now renders through NameEditScaffold, the layout
EditGroupNameScreen uses, so both edits share the heading, field, counter
and keyboard handling. Done stays the request's button.
…kdrop

The expanded deck dimmed the chat with its own scrim, which stopped at the
app bar. It now uses the blur and dim the long-press message menu uses, over
the whole screen, the back button, avatar and title included. Tapping
anywhere outside the cards collapses, including the gaps between them.

The backdrop was hand-copied in three places (message rows, their reaction
pills, the composer). It moves into a shared chatBackdrop modifier with the
same 8dp blur and 0.4 alpha; the long-press menu should look and behave as
before.
Opening a card from the expanded deck collapsed the deck first, so the
collapse ran behind the details screen as it came up. The deck now stays
expanded, and back from details returns to the list as it was.

The expanded deck drops its grab handle; drag up still collapses it. A close
button takes the collapsed pill's place in the app bar, above the backdrop.
It fades in with the backdrop and out first on collapse, so it is gone
before the pill lands where it sits.
…sule

Request details showed "•••" and X as two separate circles; iOS shows them in one
capsule. ActionCapsule in chat-ui has the photo viewer's pill shape, surface and
spacing (#1854), so the viewer can switch to it once both land. StripSurface
becomes internal for it, as #1854 also does.

The deck's close X uses the same capsule; its placement is unchanged.
@bmc08gt
bmc08gt force-pushed the feat/group-requests branch from 7ccc636 to dac2a03 Compare October 26, 2026 01:35
Opening a request from the expanded deck left the deck expanded under
the sheet, so back returned to the expanded list. The Request details
step now waits for its sheet to be fully up, then sets the deck
minimized while it is hidden. Back shows the chat with the deck in the
app bar and no collapse animation as the sheet leaves.

ChatRequestsViewModel moves to the flow's store so the step can reach
the chat's instance.
…deck

The card behind now moves up with the front card as it is swiped and shows
its request over the second half of the swipe, so the deck lands on a filled
card instead of an empty slot. The swiped card fades in at the back, the pill
eases between title lengths, and the press ripple is clipped to the pill.
The mapping could word a request's preview, but no list passed it the
requests, so a chat whose latest message is a request previewed as nothing.
The Chats and Archived lists now read the requests store and fetch a
previewed request the device doesn't hold yet, once, so the row fills in on a
cold start without opening the chat.
The chat list previewed a request as "You requested $5.00". It now reads
"You sent request for Dinner" or "Alice sent request for Dinner", with no
amount. A title that can't be opened drops the "for" part.
animateContentSize measured the pill's text at each in-between width, so a
longer title arriving in front read "Sund…" until the pill caught up. The
title and amounts now measure up to the widest pill's text room and the
easing pill clips them, so a title ellipsizes only when it is too long for
that width.
The front card now stops at one pill height and rubber-bands past it.
The card behind scales up into the front slot as you drag and shows
its request the whole way, so the pill is never empty. On release the
swiped card drops behind and springs into the back slot instead of
fading in there. Release and spring-back are springs, so a new drag
picks the deck up where it is.
The pill held the old request's width through the hand-over, then
jumped to the new one, so a longer title showed clipped and a shorter
one left a gap. The deck width is now its own spring, started when the
swipe is let go and run on the cards' spring, so every card stretches
with it as on iOS.

Release also hands over at once: the swiped card and the cards behind
finish their move from where the finger left them instead of first
carrying the swipe on to a full pill height.
While the viewer has an unpaid share in an open request, the deck leaves
the app bar for a full-width card under it: title, "You owe <share> ·
<time left>" and a Pay button that opens Request details straight into
paying. Scrolled away from the newest messages, it shrinks to a green
app-bar pill instead. Requests the viewer owes on sort to the front.

The first time this device sees an owed request, the deck expands by
itself with a NEW chip on that card and collapses after 2.5 s. It skips
this with a screen reader or reduced motion.

The pill, green pill and strip draw one stack, so they swipe the same
way. The compact pill toggles when the newest message leaves the screen
rather than tracking the scroll.
…xpand

Scrolled away from the newest messages, the strip now shrinks into the
normal grey app-bar pill instead of a green one, and grows back at the
newest messages. The deck no longer opens itself the first time it sees
an owed request: the NEW chip and the per-device store of shown requests
are gone. The owed screen-reader label is now read in the strip only.
Scrolling away from the newest messages shrinks the owed strip into the
grey app-bar pill as the finger moves; a scroll released partway settles
to the nearer end. Once it reaches the pill it stays there for the rest of
the chat visit, so scrolling back to the newest messages no longer grows
it. The strip comes back when the chat is reopened while still owing, or
when a new owed request arrives while it is open.

The pill, strip and expanded deck now share one seekable transition, so
the front card's shared bounds can morph between any two of them. The
strip consumes the scroll while it shrinks, so the timeline doesn't jump.
DeckRest holds the pinning rule and replaces deckPlacement.
Each deck card now says where the viewer stands on it, so a member can
tell which requests they've paid without opening each one (spec "Viewer
status", Figma row G).

- Paid share: a success badge with a black check at the glyph's
  bottom-right, ringed in the card surface, and "✓ You paid $5".
- Owed share: "You owe $5" in green. Creator: "Your request" in grey.
  No share: nothing extra.
- Pill and strip back cards: the standing leads the line and the
  " · $15 of $45" totals drop when the line doesn't fit.
- Expanded cards keep "$15 paid, $45 requested" on the amounts line and
  put the standing at the footer's trailing end.
- The deck's screen reader label adds the standing after the title.

ViewerStanding is resolved in the request model; tests 29-32 cover
paid, owed, creator and no share.
Replaces the six-row deadline list and the separate date-and-time picker
with one sheet: preset chips from 1 Day to 4 Weeks, a grid of today
through today + 28 days, and a Time row that appears for a custom day.
The custom time defaults to 6:00 PM, is clamped to now + 1 h through
now + 28 d - 5 min, and opens Material 3's time picker dialog.

Spec tests 34-39 are in TimeToPayTest.
The readout above the calendar becomes two fields: Starts reads
"Today, on send", and Closes shows the deadline with a white outline and
is announced live. A summary line under them names the preset or the
custom day count.

The calendar marks today with a grey circle and the closing day with a
white one, joined by a 12% white band that breaks at each week's edge.
Picking today shows the white circle alone.

The Closes value shrinks to fit instead of cutting off a custom day and
time on a phone-width field.
… selection

Time To Pay now always shows the Time row. A preset reads the current
time of day; changing it makes the preset's closing day custom at that
time, clamped as before. A tapped day keeps the last time set.

The chip highlight, the closing-day circle and the range band now move
to a new choice. The band draws as one span per week row so it has no
seams while it moves. With animations off they jump straight there.

Spec tests 34 and 38 now expect the Time row; 41-45 are new.
…n it

While the closing-day circle slid across the grid, each day it passed
turned black for a frame before the circle reached it. The text colour
now blends from white to black over the last quarter cell of the
circle's approach.

A band span that had just started growing into a row drew as a thin
capsule at the row's edge. Spans narrower than the band is tall now
fade in with their width.
The Time To Pay grid stacks the abbreviated month (8sp semibold, uppercased)
above the number on the 1st, centred as one group in the 34dp circle. The
label colour follows the cell's state: dimmed, inside the band, closing day,
or start circle (tests 46-47).
A press on a Time To Pay day lit the whole 38 dp cell, a square that ran
into the neighbouring cells. The ripple is now a 34 dp circle centred on
the cell, the size of the selected circle; the whole cell still takes the
tap.
…ircles

Each band span drew with a full rounded cap, so where it met the start or
closing-day circle the cap pinched inward under the circle's edge. A span now
rounds only the ends where the range carries on past the row edge, and stays
square where it starts or ends at a circle, including under the moving circle
while it slides. Test 48 covers the per-end rounding.
…s cards

The flow host got the current stage's back stack as its initial stack on
every recomposition. Until the user has moved off the start, the host
treats a new initial stack as a correction and swaps it in without a
transition, so History -> amount appeared instantly while later pushes
slid. The initial stack is now remembered once.

The history list now uses iOS's card rows: 12dp corners on a 6% white
fill, 16dp padding, title and date on one baseline, amounts below, and a
chevron. The list gets a 20dp inset and 12dp between items.
@bmc08gt
bmc08gt marked this pull request as ready for review October 26, 2026 19:31
* commit '5f2549710405c4215ac47fc972e0c3d22f4e8dfb':
  feat(history): show boosted reactions in transaction history (#1858)
  feat(chat): in-app alert for reactions and boosts on your messages (#1857)
  feat(chat): tap-to-show photo viewer chrome with react, reply, save and forward (#1854)

# Conflicts:
#	apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@bmc08gt
bmc08gt merged commit eba8deb into code/cash Oct 26, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: crypto Solana, keys, encryption, signing area: intents Intent construction, submission, server-side state area: network gRPC, connectivity, API, exchange rates area: payments Payments, transfers, intents, billing type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant