Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
068fb91
fix: require payment pin for quickpay
ovitrif Aug 15, 2026
76cb9ef
chore: rename changelog fragment
ovitrif Aug 15, 2026
01cc208
fix: add quickpay daily spend limit
ovitrif Aug 15, 2026
99b47d8
fix: show multiplier steps as times
ovitrif Aug 15, 2026
fbe9321
fix: include steplider labels in layout bounds
ovitrif Aug 15, 2026
d90dfb1
fix: layout steplider labels from constraints
ovitrif Aug 15, 2026
ef76b51
refactor: measure slider as one unit
ovitrif Aug 15, 2026
af4163d
fix: spacing and copy
ovitrif Aug 15, 2026
17f544c
fix: snap slider tap from current value
ovitrif Aug 15, 2026
7385c73
fix: simplify multiplier string templates
ovitrif Aug 15, 2026
af959c9
fix: harden quickpay daily spend limits
ovitrif Aug 18, 2026
7cdae86
fix: localize error messages and texts
ovitrif Aug 18, 2026
3593678
refactor: use USD constant
ovitrif Aug 18, 2026
4761c75
feat: use quickpay routing fee on ui
ovitrif Aug 18, 2026
b64cc53
merge: sync master into quickpay daily limit
ovitrif Aug 18, 2026
07a88a2
fix: harden quickpay spend and slider
ovitrif Aug 18, 2026
a254ba2
test: fix quickpay day spend assertion
ovitrif Aug 18, 2026
c6136d7
merge: sync master into quickpay daily limit
ovitrif Aug 18, 2026
d98ad17
fix: wrap cache store test lines
ovitrif Aug 18, 2026
c874d2c
merge: sync master into quickpay daily limit
ovitrif Aug 19, 2026
c0806a6
fix: account quickpay daily spend in usd cents
ovitrif Aug 19, 2026
ddf9b71
test: fix quickpay viewmodel mock setup
ovitrif Aug 19, 2026
6d7bf18
refactor: move quickpay spend ledger to repo
ovitrif Aug 19, 2026
1bb0498
chore: cleanup imports
ovitrif Aug 19, 2026
37017df
fix: keep quickpay events and spend in sync
ovitrif Aug 19, 2026
ffe28d9
fix: release spend without pending gate
ovitrif Aug 19, 2026
68cba13
test: drop unshipped sat spend decode
ovitrif Aug 19, 2026
ef74df4
test: drop duplicate setmain in quickpay
ovitrif Aug 19, 2026
5c21813
refactor: inline quickpay day key
ovitrif Aug 19, 2026
b0f56d5
refactor: move quickpay types to repo
ovitrif Aug 19, 2026
d46ab05
refactor: drop experimentaltime opt-in
ovitrif Aug 19, 2026
0f59bc6
chore: fix quickpay import order
ovitrif Aug 19, 2026
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 @@ -34,12 +34,14 @@ class QuickPaySettingsScreenTest {
QuickPaySettingsScreenContent(
isQuickPayEnabled = true,
quickPayAmount = 5,
quickPayDailyLimitMultiplier = 5,
)
}
}

composeTestRule.onNodeWithTag("QuickpayToggle").assertIsDisplayed()
composeTestRule.onNodeWithTag("quickpay_amount_slider").assertIsDisplayed()
composeTestRule.onNodeWithTag("QuickpayAmountSlider").assertIsDisplayed()
composeTestRule.onNodeWithTag("QuickpayDailyLimitSlider").assertIsDisplayed()
}

@Test
Expand All @@ -52,6 +54,7 @@ class QuickPaySettingsScreenTest {
QuickPaySettingsScreenContent(
isQuickPayEnabled = false,
quickPayAmount = 5,
quickPayDailyLimitMultiplier = 5,
onToggleQuickPay = { enabled ->
toggleCalled = true
toggleValue = enabled
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import to.bitkit.models.BalanceState
import to.bitkit.models.FxRate
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.WalletScope
import to.bitkit.repositories.QuickPaySpendReservation
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -164,6 +165,9 @@ data class AppCacheData(
val backgroundReceive: NewTransactionSheetDetails? = null,
val addressSearchLastUsedReceiveIndexes: Map<String, Int> = mapOf(),
val addressSearchLastUsedChangeIndexes: Map<String, Int> = mapOf(),
val quickPaySpendDayKey: String = "",
Comment thread
ovitrif marked this conversation as resolved.
val quickPaySpentCentsToday: Long = 0L,
val quickPayReservations: Map<String, QuickPaySpendReservation> = emptyMap(),
) {
fun isActivityDeleted(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in deletedActivities ||
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ data class SettingsData(
val bgPaymentsIntroSeen: Boolean = false,
val isQuickPayEnabled: Boolean = false,
val quickPayAmount: Int = 5,
val quickPayDailyLimitMultiplier: Int = 5,
val lightningSetupStep: Int = 0,
val isPinEnabled: Boolean = false,
val isBiometricEnabled: Boolean = false,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/models/Currency.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ data class ConvertedAmount(
val sats: Long,
val locale: Locale = Locale.getDefault(),
) {
fun toUsdCents(): Long = value.movePointRight(2).setScale(0, RoundingMode.HALF_UP).toLong()

val isSymbolSuffix: Boolean get() = currency in SUFFIX_SYMBOL_CURRENCIES

data class BitcoinDisplayComponents(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ class PaymentPendingException(val paymentHash: String) : AppError("Payment pendi
sealed interface PendingPaymentResolution {
val paymentHash: String

data class Success(override val paymentHash: String) : PendingPaymentResolution
data class Success(
override val paymentHash: String,
val amountWithFeeSats: Long? = null,
) : PendingPaymentResolution

data class Failure(
override val paymentHash: String,
val reason: PaymentFailureReason? = null,
Expand Down
175 changes: 175 additions & 0 deletions app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package to.bitkit.repositories

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import to.bitkit.data.AppCacheData
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.USD
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock

@Singleton
class QuickPayRepo @Inject constructor(
private val cacheStore: CacheStore,
private val settingsStore: SettingsStore,
private val currencyRepo: CurrencyRepo,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val clock: Clock,
) {
companion object {
private const val TAG = "QuickPayRepo"
}

suspend fun spentCentsToday(): Result<Long> = withContext(ioDispatcher) {
runSuspendCatching {
cacheStore.data.first().spendFor(currentDayKey()).spentCents
}
}

suspend fun canApply(amountSats: ULong): Result<Boolean> = withContext(ioDispatcher) {
runSuspendCatching {
val settings = settingsStore.data.first()
if (!settings.isQuickPayEnabled || amountSats == 0uL) return@runSuspendCatching false

val thresholdSats = currencyRepo.convertFiatToSats(
settings.quickPayAmount.toDouble(),
USD,
).getOrNull() ?: return@runSuspendCatching false
if (amountSats > thresholdSats) return@runSuspendCatching false

val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()
?: return@runSuspendCatching false
val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount)
val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier)
val spentCentsToday = cacheStore.data.first().spendFor(currentDayKey()).spentCents
if (spentCentsToday + reserveCents <= capCents) return@runSuspendCatching true

Logger.info(
"Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'",
context = TAG,
)
false
}
}

suspend fun tryReserve(amountSats: ULong): Result<QuickPaySpendReservation?> = withContext(ioDispatcher) {
runSuspendCatching {
val settings = settingsStore.data.first()
val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse {
throw QuickPayConversionError()
}
val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount)
val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier)
val dayKey = currentDayKey()
var reserved: QuickPaySpendReservation? = null
cacheStore.update {
val spend = it.spendFor(dayKey)
if (spend.spentCents + amountCents > capCents) return@update it
reserved = QuickPaySpendReservation(amountCents = amountCents, dayKey = spend.dayKey)
it.copy(
quickPaySpendDayKey = spend.dayKey,
quickPaySpentCentsToday = spend.spentCents + amountCents,
)
}
reserved
}
}

suspend fun remember(
paymentHash: String,
reservation: QuickPaySpendReservation,
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
if (paymentHash.isBlank()) return@runSuspendCatching
cacheStore.update {
it.copy(
quickPayReservations = it.quickPayReservations + (paymentHash to reservation),
)
}
}
}

suspend fun reservation(paymentHash: String): Result<QuickPaySpendReservation?> = withContext(ioDispatcher) {
runSuspendCatching {
if (paymentHash.isBlank()) return@runSuspendCatching null
cacheStore.data.first().quickPayReservations[paymentHash]
}
}

suspend fun release(paymentHash: String): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
if (paymentHash.isBlank()) return@runSuspendCatching
cacheStore.update { data ->
val reservation = data.quickPayReservations[paymentHash] ?: return@update data
val remaining = data.quickPayReservations - paymentHash
val spend = data.spendFor(reservation.dayKey)
if (reservation.dayKey != spend.dayKey) {
return@update data.copy(quickPayReservations = remaining)
}
data.copy(
quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L),
quickPayReservations = remaining,
)
}
}
}

suspend fun releaseUnbound(reservation: QuickPaySpendReservation): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
cacheStore.update {
if (reservation.dayKey != it.quickPaySpendDayKey) return@update it
it.copy(
quickPaySpentCentsToday = (it.quickPaySpentCentsToday - reservation.amountCents).coerceAtLeast(0L),
)
}
}
}

suspend fun clear(paymentHash: String): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
if (paymentHash.isBlank()) return@runSuspendCatching
cacheStore.update {
if (paymentHash !in it.quickPayReservations) return@update it
it.copy(quickPayReservations = it.quickPayReservations - paymentHash)
}
}
}

private fun currentDayKey(): String =
clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
}

@Serializable
data class QuickPaySpendReservation(
val amountCents: Long,
val dayKey: String,
)

private data class QuickPayDaySpend(
val dayKey: String,
val spentCents: Long,
)

private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long =
thresholdUsd.toLong() * 100L * multiplier.toLong()

private fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long =
minOf(convertedCents, thresholdUsd.toLong() * 100L)

class QuickPayConversionError : AppError("Currency conversion failed")

private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when {
quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L)
dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday)
else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday)
}
Loading
Loading