Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1005,9 +1005,9 @@
<string name="label_of">of</string>
<string name="action_swipeToTip">Swipe to Tip</string>
<string name="content_description_customTipAmount">Enter a custom amount</string>
<string name="subtitle_tipHintMinimum">Minimum tip %1$s</string>
<string name="error_title_tipMinimum">%1$s Minimum</string>
<string name="error_description_tipMinimum">Increase the amount to send</string>
<string name="subtitle_tipHintMinimum">%1$s minimum</string>
<string name="error_title_tipMinimum">%1$s Minimum Tip</string>
<string name="error_description_tipMinimum">Please enter a higher amount</string>
<string name="label_viaTipCard">via Tip Card</string>

<!-- Minimum-tip entry (nodes 9541:10951, 9553:113170). Backed by the profile's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,8 +258,8 @@ 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.
// Only the payment that opens a tip DM is a tip — it buys the conversation, and it is the one
// the recipient's fee applies to. Everything after it, and every contact DM, is a plain send.
private fun amountStyle(isTip: Boolean) = AmountEntryStyle(
actionLabel = AmountEntryLabel.Plain(
resources.getString(
Expand All @@ -280,20 +281,61 @@ 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 amountStyleFlow by lazy {
isTipFlow
.map { amountStyle(isTip = it) }
openingTipRecipientFlow
.map { amountStyle(isTip = it != null) }
.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 user this payment would open a tip DM with — null once the conversation exists, and null
* for a contact DM. It decides both the floor and the word the entry uses: the fee, and calling
* the payment a tip, belong to the one that opens the chat.
*/
private val openingTipRecipientFlow by lazy {
combine(tipRecipientFlow, isChatInitialized) { recipient, initialized ->
recipient?.takeUnless { initialized }
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

/**
* 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)
openingTipRecipientFlow
.flatMapLatest { recipient ->
if (recipient == null) flowOf(null)
else tipPaymentDelegate.minimumToOpenDmWith(recipient.profile)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

val amountDelegate by lazy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@ internal class TipAmountEntryViewModel @Inject constructor(
val entered = amountDelegate.state.value.enteredAmount
if (entered <= 0.0) return@onEach
val amount = Fiat(entered, stateFlow.value.currency)
// Floor at the lowest preset — a custom amount can't undercut the presets.
// The floor the coordinator resolved for this card: the recipient's own minimum
// when this tip opens the DM, the system minimum once one exists.
val min = tippingCoordinator.minTipAmount.value
if (min != null && amount.valueLessThan(min)) {
BottomBarManager.showAlert(
// Info, not an alert: nothing failed, the amount is just under the floor —
// matching the same prompt in the chat and tip-card paths.
BottomBarManager.showInfo(
title = resources.getString(R.string.error_title_tipMinimum, min.formatted()),
message = resources.getString(R.string.error_description_tipMinimum),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Fiat?> =
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<Fiat?> =
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<Fiat?> = 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<TipPresets>): ResolvedUserFlags =
mockk {
every { tipPresets } returns ResolvedFlag(presets, FieldOverride.None)
Expand Down Expand Up @@ -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<Byte>(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<Byte>(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())
}
}
Loading
Loading