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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.GroupAccess
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.internal.extensions.fractionDigits
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Rate
import com.getcode.opencode.model.financial.Token
Expand Down Expand Up @@ -234,8 +236,9 @@ internal class CreateGroupViewModel @Inject constructor(
/**
* The keypad behind the `…` chip.
*
* No ceiling and no floor: a group can ask for any balance, and unlike the minimum-to-chat fee
* there is nothing stored to compare a change against. The creator's own balance is a bound on
* No ceiling, and a floor of one cent (the server's minimum transfer value): a group can ask
* for any balance above that, and unlike the minimum-to-chat fee there is nothing stored to
* compare a change against. The creator's own balance is a bound on
* *creating*, not on typing, and the form says so where the requirement is shown.
*/
val amountDelegate = AmountEntryDelegate(
Expand Down Expand Up @@ -311,13 +314,10 @@ internal class CreateGroupViewModel @Inject constructor(
.filterIsInstance<Event.ConfirmCustomAmount>()
.map { amountDelegate.state.value.enteredAmount }
.onEach { entered ->
if (entered <= 0.0) return@onEach
// The keypad enters in the preferred currency; the rule is compared against a USD
// balance, so it is stored in USD rather than in whatever was typed.
val local = Fiat(entered, stateFlow.value.rate.currency)
dispatchEvent(
Event.OnAmountSelected(local.convertingToUsdIfNeeded(stateFlow.value.rate))
)
// Below one minimum unit the server answers INVALID_RULES, so it is rejected here the
// same way an empty entry is.
val amount = minimumBalanceFor(entered, stateFlow.value.rate) ?: return@onEach
dispatchEvent(Event.OnAmountSelected(amount))
}
.launchIn(viewModelScope)

Expand Down Expand Up @@ -511,6 +511,22 @@ internal class CreateGroupViewModel @Inject constructor(
}

companion object {
/**
* The stored requirement for a keypad [entered] amount, or null when it falls below the
* currency's minimum transfer value (one unit of its last decimal place).
*
* The keypad enters in the preferred currency; the rule is compared against a USD balance,
* so it is stored in USD rather than in whatever was typed, rounded to USD's decimals
* (cents) so the server never sees a fraction of one.
*/
@VisibleForTesting
internal fun minimumBalanceFor(entered: Double, rate: Rate): Fiat? {
if (entered <= 0.0) return null
val usd = Fiat(entered, rate.currency).convertingToUsdIfNeeded(rate)
val rounded = usd.rounded(CurrencyCode.USD.fractionDigits)
return rounded.takeIf { it.decimalValue > 0.0 }
}

/** One listener minimum balance of [amount], in whatever [currency] names. */
private fun rulesFor(currency: GroupCurrency, amount: Fiat) = ChatRules(
listener = listOf(ChatRuleRequirement.MinimumBalance(amount, currency.mints)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.flipcash.app.tipping.internal

import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Rate
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

/** The server rejects a group minimum balance below one cent, so the keypad entry is floored there. */
class MinimumBalanceFloorTest {

private val jpy = Rate(fx = 150.0, currency = CurrencyCode.JPY)

@Test
fun `a conversion that rounds to zero cents is rejected`() {
// 0.5 JPY at 150 JPY/USD is 0.0033 USD.
assertNull(CreateGroupViewModel.minimumBalanceFor(0.5, jpy))
}

@Test
fun `one yen rounds up to one cent rather than being sent as a fraction`() {
// 1 JPY is 0.00667 USD; half-up to cents is the minimum unit, as on iOS.
assertEquals(0.01, CreateGroupViewModel.minimumBalanceFor(1.0, jpy)!!.decimalValue, 1e-9)
}

@Test
fun `zero and negative entries are rejected`() {
assertNull(CreateGroupViewModel.minimumBalanceFor(0.0, Rate.oneToOne))
assertNull(CreateGroupViewModel.minimumBalanceFor(-1.0, Rate.oneToOne))
}

@Test
fun `a sub-cent USD entry is rejected`() {
assertNull(CreateGroupViewModel.minimumBalanceFor(0.004, Rate.oneToOne))
}

@Test
fun `a fractional-cent conversion rounds to cents`() {
// 2 JPY at 150 JPY/USD is 0.01333 USD.
val one = CreateGroupViewModel.minimumBalanceFor(2.0, jpy)
assertNotNull(one)
assertEquals(CurrencyCode.USD, one.currencyCode)
assertEquals(0.01, one.decimalValue, 1e-9)

// 100 JPY is 0.6667 USD.
assertEquals(0.67, CreateGroupViewModel.minimumBalanceFor(100.0, jpy)!!.decimalValue, 1e-9)
}

@Test
fun `exactly one cent is accepted`() {
assertEquals(0.01, CreateGroupViewModel.minimumBalanceFor(0.01, Rate.oneToOne)!!.decimalValue, 1e-9)
}
}
Loading