From 71b9fd9fb255bd747a4365159a915ce15f45f6a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 16:11:07 -0400 Subject: [PATCH 1/3] feat(tipping): charge the recipient's DM-init fee on the tip that opens the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the in-chat amount entry and the custom tip entry floored at the regional preset minimum, which is not the amount the recipient set. #1360 gave users their own minimum, `UserProfile.minDmChatInitFee` — the fee another user pays to open a DM with them — and neither entry read it. The fee buys the conversation, so only the payment that opens it pays: - `TipPaymentDelegate.minimumToOpenDmWith` is that floor, converted through USD into the currency the sender is entering in, falling back to the regional preset when the recipient charges nothing or a rate is missing. - `minimumTipFor(userId, ...)` charges it while no DM with that user exists and the system minimum once one does. The tip card follows it: presets below the floor are dropped from the modal, and `confirmTip` checks it, since a preset chip never passes through the amount entry. - The in-chat entry applies it to the send that opens the chat and drops the floor entirely afterwards. A contact DM never had one. The standing hint reads "$5 minimum" rather than "Minimum tip $5", matching the prompt that blocks a send below it. --- .../core/src/main/res/values/strings.xml | 2 +- .../app/messenger/internal/ChatViewModel.kt | 51 +++++++-- .../shared/payments/TipPaymentDelegate.kt | 45 +++++++- .../shared/payments/TipPaymentDelegateTest.kt | 101 ++++++++++++++++++ .../shared/tipping/TippingCoordinator.kt | 48 +++++++-- 5 files changed, 231 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 223ee5c2e8..ddadd115e7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1005,7 +1005,7 @@ of Swipe to Tip Enter a custom amount - Minimum tip %1$s + %1$s minimum %1$s Minimum Increase the amount to send via Tip Card diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 1fc31becc5..e0cbec0105 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -66,6 +66,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -257,8 +258,9 @@ internal class ChatViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } - // The amount entry adapts to the chat type: a tip DM swipes to *tip* and enforces the minimum - // tip (from the tip payment delegate); a contact DM swipes to *send* with no minimum. + // The amount entry adapts to the chat type: a tip DM swipes to *tip*, a contact DM to *send*. + // Whether there is a minimum to enforce is a separate question — see [minAmountFlow] — and the + // style degrades to the "enter up to" ceiling hint on its own when there is none. private fun amountStyle(isTip: Boolean) = AmountEntryStyle( actionLabel = AmountEntryLabel.Plain( resources.getString( @@ -280,8 +282,14 @@ internal class ChatViewModel @Inject constructor( }, ) - private val isTipFlow = stateFlow - .map { it.participant is ChatParticipant.TipUser } + // The counterparty of a tip DM, whose server profile carries the fee they charge to open a DM. + // Null for a contact DM: it is addressed by phone number and there is no profile to read one off. + private val tipRecipientFlow = stateFlow + .map { it.participant as? ChatParticipant.TipUser } + .distinctUntilChanged() + + private val isTipFlow = tipRecipientFlow + .map { it != null } .distinctUntilChanged() private val amountStyleFlow by lazy { @@ -290,10 +298,39 @@ internal class ChatViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), amountStyle(isTip = false)) } + /** + * Whether this conversation already exists. Members are the same signal + * [com.flipcash.shared.chat.DmChatResolver.getChatId] calls initialized: a chat the server has + * created has a member row, one derived from a user id alone does not. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val isChatInitialized by lazy { + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMembers(it) } + .map { it.isNotEmpty() } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + } + + /** + * The floor the entry enforces, and only for the payment that opens a tip DM. + * + * What the recipient sets is the fee to *open* a DM with them, so it gates that first payment + * and nothing after it: once the conversation exists, sending cash in it has no minimum at all. + * A contact DM never has one. + */ + @OptIn(ExperimentalCoroutinesApi::class) private val minAmountFlow by lazy { - combine(isTipFlow, tipPaymentDelegate.minTipAmount) { isTip, tipMin -> - if (isTip) tipMin else null - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + combine(tipRecipientFlow, isChatInitialized) { recipient, initialized -> + recipient?.takeUnless { initialized } + } + .distinctUntilChanged() + .flatMapLatest { recipient -> + if (recipient == null) flowOf(null) + else tipPaymentDelegate.minimumToOpenDmWith(recipient.profile) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } val amountDelegate by lazy { diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt index d8102bc60d..be18c8fe3e 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt @@ -4,6 +4,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.TipOrigin +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.buildTipDmPaymentMetadata import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatCoordinator @@ -21,10 +22,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -116,13 +120,39 @@ class TipPaymentDelegate @Inject constructor( /** * The smallest tippable amount — the presets' [com.flipcash.services.models.TipPresets.minimum] * in the user's preferred currency. This is the dedicated minimum, NOT the lowest tier (`low`), - * which typically sits above it. The amount entry surfaces it as a "minimum tip" hint and blocks - * custom amounts below it. Null until presets resolve. + * which typically sits above it. Null until presets resolve. + * + * The regional floor, which applies to a recipient who has set no minimum of their own — an + * amount entry asks [minimumTipFor] for the floor to enforce, not this. */ val minTipAmount: StateFlow = resolvedPresets.map { it?.minimum } .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + /** + * The floor for the tip that *opens* a DM with [recipient]: the fee they charge to be written + * to, expressed in the sender's preferred currency, or the regional [minTipAmount] when they + * charge none — the server applies its own default in that case, and that preset is what it is. + * + * The fee is stored in whatever currency the recipient set it in, so it is converted through USD + * to the currency the sender is entering in. A missing rate for either side falls back to the + * preset rather than stating a floor in a currency the entry isn't using. + */ + fun minimumToOpenDmWith(recipient: UserProfile?): Flow = + combine(minTipAmount, exchange.observePreferredRate()) { preset, rate -> + recipient?.minDmChatInitFee?.inCurrency(rate.currency) ?: preset + } + + /** + * The floor for a tip to [userId]. The fee buys the conversation, so only the tip that opens it + * pays [minimumToOpenDmWith]; once a DM with them exists, every tip after it sits on the system + * [minTipAmount] like any other. + */ + fun minimumTipFor(userId: ID, recipient: UserProfile?): Flow = flow { + val opensTheChat = chatCoordinator.getChatId(userId).isFailure + emitAll(if (opensTheChat) minimumToOpenDmWith(recipient) else minTipAmount) + } + /** * Whether [amount] exceeds the per-transaction send limit for its currency — the amount entry * gates on this before committing. Balance is enforced separately at send time; false when no @@ -171,6 +201,17 @@ class TipPaymentDelegate @Inject constructor( } } + /** + * This amount in [target], routed through USD — the only rate every currency has. Null when + * either leg has no rate, which is the caller's cue to fall back rather than mix currencies. + */ + private fun Fiat.inCurrency(target: CurrencyCode): Fiat? { + if (currencyCode == target) return this + val usd = exchange.rateToUsd(currencyCode)?.let { convertingTo(it) } ?: return null + if (target == CurrencyCode.USD) return usd.rounded() + return exchange.rateFor(target)?.let { usd.convertingTo(it).rounded() } + } + /** * The built-in fallback presets — the [DEFAULT_USD_MINIMUM] and [DEFAULT_USD_PRESETS] tiers (in * USD), localized to [preferred] via the current exchange rate when the user isn't on USD. Leaves diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt index 6309224a96..4747b53ba4 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt @@ -7,6 +7,8 @@ import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.TipPresets +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatCoordinator import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange @@ -16,6 +18,7 @@ import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.SendLimit import com.getcode.solana.keys.Mint +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +46,21 @@ class TipPaymentDelegateTest { SendLimit(nextTransaction = 100.0, maxPerTransaction = 500.0, maxPerDay = 1000.0) } + private fun recipient(fee: Fiat?) = UserProfile( + displayName = "Alice", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + minDmChatInitFee = fee, + ) + + /** Presets whose USD region carries a $1 minimum — the floor when a recipient sets none. */ + private fun usdPresets() = MutableStateFlow( + resolvedFlagsWith( + listOf(TipPresets(region = "usd", minimum = 1.0, low = 2.0, medium = 5.0, high = 10.0)), + ), + ) + private fun resolvedFlagsWith(presets: List): ResolvedUserFlags = mockk { every { tipPresets } returns ResolvedFlag(presets, FieldOverride.None) @@ -135,4 +153,87 @@ class TipPaymentDelegateTest { assertEquals(1.0, min!!.toDouble()) assertEquals(listOf(5.0, 10.0, 20.0), presets.map { it.toDouble() }) } + + @Test + fun `minimumToOpenDmWith is the recipient's own fee, not the regional preset`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + + val recipient = recipient(Fiat(5.0, CurrencyCode.USD)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(5.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + + @Test + fun `minimumToOpenDmWith converts the recipient's fee into the currency being entered`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 2.0, currency = CurrencyCode.EUR)) + // The fee was set in CAD at half a dollar to the dollar; the sender enters in EUR at two. + every { exchange.rateToUsd(CurrencyCode.CAD) } returns Rate(fx = 0.5, currency = CurrencyCode.USD) + every { exchange.rateFor(CurrencyCode.EUR) } returns Rate(fx = 2.0, currency = CurrencyCode.EUR) + + val recipient = recipient(Fiat(10.0, CurrencyCode.CAD)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + // CAD 10 → USD 5 → EUR 10. + assertEquals(10.0, min!!.toDouble()) + assertEquals(CurrencyCode.EUR, min.currencyCode) + } + + @Test + fun `minimumToOpenDmWith falls back to the preset when the recipient charges nothing`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + + val delegate = buildDelegate() + + assertEquals(1.0, delegate.minimumToOpenDmWith(recipient(fee = null)).first { it != null }!!.toDouble()) + assertEquals(1.0, delegate.minimumToOpenDmWith(null).first { it != null }!!.toDouble()) + } + + @Test + fun `minimumToOpenDmWith falls back to the preset rather than state a floor in another currency`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + // No rate for the currency the recipient set their fee in. + every { exchange.rateToUsd(CurrencyCode.CAD) } returns null + + val recipient = recipient(Fiat(10.0, CurrencyCode.CAD)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(1.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + + @Test + fun `minimumTipFor charges the recipient's fee while no chat with them exists`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + val userId = listOf(1, 2, 3) + coEvery { chatCoordinator.getChatId(userId) } returns + Result.failure(IllegalStateException("no DM yet")) + + val min = buildDelegate() + .minimumTipFor(userId, recipient(Fiat(5.0, CurrencyCode.USD))) + .first { it != null } + + assertEquals(5.0, min!!.toDouble()) + } + + @Test + fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + val userId = listOf(1, 2, 3) + coEvery { chatCoordinator.getChatId(userId) } returns Result.success(ChatId(byteArrayOf(9))) + + // The fee opened that chat; it isn't charged again. + val min = buildDelegate() + .minimumTipFor(userId, recipient(Fiat(5.0, CurrencyCode.USD))) + .first { it != null } + + assertEquals(1.0, min!!.toDouble()) + } } diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index fafeb48a62..32f88be7ba 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -92,6 +92,10 @@ class TippingCoordinator @Inject constructor( private val _userId = MutableStateFlow(null) + // The resolved card owner's profile, kept because it carries the fee they charge to open a DM + // with them — the floor a first tip has to clear. Set alongside _userId when a card resolves. + private val _recipient = MutableStateFlow(null) + // Whether the viewer can afford at least the minimum tip, evaluated when a tip // card is resolved. Surfaced through [selection] so the scanner can hide the tip // modal and prompt to add money without re-checking balance itself. @@ -113,8 +117,19 @@ class TippingCoordinator @Inject constructor( /** The largest tippable amount (send-limit ∧ balance), surfaced by the amount entry. */ val maxTipAmount: StateFlow get() = tipPaymentDelegate.maxTipAmount - /** The smallest tippable amount (lowest preset tier), surfaced by the amount entry. */ - val minTipAmount: StateFlow get() = tipPaymentDelegate.minTipAmount + /** + * The smallest tippable amount for the card on screen: the fee its owner charges to open a DM + * when this tip would open one, and the system minimum otherwise. Surfaced by the amount entry + * as a standing hint and enforced by [confirmTip]. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val minTipAmount: StateFlow = + combine(_userId, _recipient) { userId, recipient -> userId to recipient } + .flatMapLatest { (userId, recipient) -> + if (userId == null) tipPaymentDelegate.minTipAmount + else tipPaymentDelegate.minimumTipFor(userId, recipient) + } + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) /** The combined tip selection (amount chosen in the modal + app-global token + send state). */ override val selection: StateFlow = @@ -130,7 +145,15 @@ class TippingCoordinator @Inject constructor( ) }, tipPaymentDelegate.tipPresets, - ) { state, presets -> state.copy(presets = presets) } + // A preset below the recipient's floor would be rejected on send, so it isn't offered. + // Presets normally all clear it; the floor only rises above them when the recipient has + // set a minimum of their own that high. + ) { state, presets -> + val floor = state.minimum + state.copy( + presets = presets.filter { floor == null || !it.valueLessThan(floor) }, + ) + } .stateIn(scope, SharingStarted.WhileSubscribed(5_000), TipSelectionState()) init { @@ -162,6 +185,18 @@ class TippingCoordinator @Inject constructor( setSendState(LoadingSuccessState(loading = true)) val token = selectedToken.firstOrNull() ?: return@launch + // The floor, checked here as well as in the amount entry: a preset chip never passes + // through that entry. + val floor = minTipAmount.value + if (floor != null && amount.value.valueLessThan(floor)) { + setSendState(LoadingSuccessState()) + BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_tipMinimum, floor.formatted()), + message = resources.getString(R.string.error_description_tipMinimum), + ) + return@launch + } + // Fast-fail before the loading state if the tip exceeds the token balance: // prompt to add money (or enter a smaller amount) instead of attempting a send. val balanceInLocal = tokenCoordinator.balanceForToken(token).convertingTo(rate) @@ -298,7 +333,7 @@ class TippingCoordinator @Inject constructor( */ suspend fun resolveTipCard(userId: ID): Result = resolveProfile(userId) - .onSuccess { onCardResolved(userId) } + .onSuccess { onCardResolved(userId, it) } .map { tipCard(userId, it) } /** @@ -321,7 +356,7 @@ class TippingCoordinator @Inject constructor( // link lands. The id off the wire has no such window. // Mirrors iOS TipFlow.prepare's `guard userID != session.userID`. if (userId == currentUserId) throw OwnTipCard(username) - onCardResolved(userId) + onCardResolved(userId, profile) tipCard(userId, profile) } @@ -331,9 +366,10 @@ class TippingCoordinator @Inject constructor( * minimum-tip and per-amount affordability are enforced downstream — the amount entry's * below-min / over-balance gates and confirmTip. */ - private suspend fun onCardResolved(userId: ID) { + private suspend fun onCardResolved(userId: ID, profile: UserProfile) { _canTip.value = tokenCoordinator.hasGiveableBalance() _userId.value = userId + _recipient.value = profile vibrator.vibrate() } From 9fa73649673b3e5cf1bb8bc1210db3643093dd28 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 16:26:55 -0400 Subject: [PATCH 2/3] fix(tipping): match the below-minimum prompt to its design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt read "$5.00 Minimum / Increase the amount to send"; node 9553:20236 has "$5.00 Minimum Tip / Please enter a higher amount". Both strings are shared by the three places that raise it — the chat send, the tip card, and the custom amount entry. The custom entry raised it as a destructive alert while the other two used the info style, so it also moves to info: nothing has failed, the amount is just under the floor. --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- .../app/tipping/internal/TipAmountEntryViewModel.kt | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ddadd115e7..013f48346e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1006,8 +1006,8 @@ Swipe to Tip Enter a custom amount %1$s minimum - %1$s Minimum - Increase the amount to send + %1$s Minimum Tip + Please enter a higher amount via Tip Card