mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge branch 'folio-amount-bounds' into trial-merge
This commit is contained in:
commit
40bd5322b7
8 files changed
+122
-3
No files matched your search
@@ -49,6 +49,9 @@ kotlin {
|
|||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation.compose)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -3,6 +3,7 @@ package app.folio.feature.ledger
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.folio.core.data.Account
|
import app.folio.core.data.Account
|
||||||
|
import app.folio.core.data.MAX_TRANSACTION_AMOUNT_CENTS
|
||||||
import app.folio.core.data.Repository
|
import app.folio.core.data.Repository
|
||||||
import app.folio.core.data.TxnType
|
import app.folio.core.data.TxnType
|
||||||
import app.folio.navigation.Navigator
|
import app.folio.navigation.Navigator
|
||||||
@@ -87,6 +88,10 @@ class AddTransactionViewModel(
|
|||||||
form.update { it.copy(error = "Amount must be greater than zero") }
|
form.update { it.copy(error = "Amount must be greater than zero") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (cents > MAX_TRANSACTION_AMOUNT_CENTS) {
|
||||||
|
form.update { it.copy(error = "Amount is too large (max \$1,000,000.00)") }
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repository.createTransaction(accountId, s.type, cents, s.note)
|
repository.createTransaction(accountId, s.type, cents, s.note)
|
||||||
|
|||||||
@@ -54,9 +54,8 @@ fun parseCents(input: String): Long? {
|
|||||||
val fracPadded = (frac + "00").substring(0, 2)
|
val fracPadded = (frac + "00").substring(0, 2)
|
||||||
val wholeLong = whole.toLongOrNull() ?: return null
|
val wholeLong = whole.toLongOrNull() ?: return null
|
||||||
val fracLong = fracPadded.toLongOrNull() ?: return null
|
val fracLong = fracPadded.toLongOrNull() ?: return null
|
||||||
val total = wholeLong * 100 + fracLong
|
if (wholeLong > (Long.MAX_VALUE - fracLong) / 100) return null
|
||||||
if (total < 0) return null
|
return wholeLong * 100 + fracLong
|
||||||
return total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
|
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package app.folio.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ParseCentsTest {
|
||||||
|
@Test
|
||||||
|
fun parsesEverydayAmountsExactly() {
|
||||||
|
assertEquals(1L, parseCents("0.01"))
|
||||||
|
assertEquals(1234L, parseCents("12.34"))
|
||||||
|
assertEquals(1250L, parseCents("12.5"))
|
||||||
|
assertEquals(1200L, parseCents("12.0"))
|
||||||
|
assertEquals(100000L, parseCents("1000"))
|
||||||
|
assertEquals(123456L, parseCents("1,234.56"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsEighteenDigitWholeThatWrapsToAPositiveLong() {
|
||||||
|
assertNull(parseCents("999999999999999999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsSeventeenDigitWholeThatWrapsToANegativeLong() {
|
||||||
|
assertNull(parseCents("99999999999999999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsNineteenDigitWholeThatNoLongerFitsALong() {
|
||||||
|
assertNull(parseCents("9999999999999999999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsTheLargestRepresentableAmountAndRejectsOneCentMore() {
|
||||||
|
assertEquals(Long.MAX_VALUE, parseCents("92233720368547758.07"))
|
||||||
|
assertNull(parseCents("92233720368547758.08"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ kotlin {
|
|||||||
api(libs.sqldelight.coroutines.extensions)
|
api(libs.sqldelight.coroutines.extensions)
|
||||||
api(libs.sqldelight.async.extensions)
|
api(libs.sqldelight.async.extensions)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.sqldelight.android.driver)
|
implementation(libs.sqldelight.android.driver)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import dev.zacsweers.metro.Inject
|
|||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
const val MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000L
|
||||||
|
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
class Repository(private val store: LedgerStore) {
|
class Repository(private val store: LedgerStore) {
|
||||||
@@ -27,6 +29,7 @@ class Repository(private val store: LedgerStore) {
|
|||||||
|
|
||||||
suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
|
suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
|
||||||
require(amount > 0) { "Amount must be greater than zero" }
|
require(amount > 0) { "Amount must be greater than zero" }
|
||||||
|
require(amount <= MAX_TRANSACTION_AMOUNT_CENTS) { "Amount is too large (max \$1,000,000.00)" }
|
||||||
requireNotNull(getAccount(accountId)) { "Account not found" }
|
requireNotNull(getAccount(accountId)) { "Account not found" }
|
||||||
val txn = Transaction(
|
val txn = Transaction(
|
||||||
id = Platform.makeId(),
|
id = Platform.makeId(),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class FakeLedgerStore : LedgerStore {
|
||||||
|
override val accounts = MutableStateFlow<List<Account>>(emptyList())
|
||||||
|
override val transactions = MutableStateFlow<List<Transaction>>(emptyList())
|
||||||
|
override val session = MutableStateFlow<Session?>(null)
|
||||||
|
|
||||||
|
override suspend fun accountExistsByName(name: String): Boolean =
|
||||||
|
accounts.value.any { it.name == name }
|
||||||
|
|
||||||
|
override suspend fun insertAccount(id: String, name: String, createdAt: Long) {
|
||||||
|
accounts.value = accounts.value + Account(id, name, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertTxn(
|
||||||
|
id: String,
|
||||||
|
accountId: String,
|
||||||
|
type: TxnType,
|
||||||
|
amount: Long,
|
||||||
|
note: String,
|
||||||
|
createdAt: Long,
|
||||||
|
) {
|
||||||
|
transactions.value = transactions.value + Transaction(id, accountId, type, amount, note, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertSession(user: String, loggedInAt: Long) {
|
||||||
|
session.value = Session(user, loggedInAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearSession() {
|
||||||
|
session.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RepositoryTest {
|
||||||
|
@Test
|
||||||
|
fun rejectsAmountAboveOneMillionDollars() = runTest {
|
||||||
|
val repository = Repository(FakeLedgerStore())
|
||||||
|
val account = repository.createAccount("Checking")
|
||||||
|
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
repository.createTransaction(account.id, TxnType.credit, 100_000_001L, "")
|
||||||
|
}
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
repository.createTransaction(account.id, TxnType.credit, Long.MAX_VALUE, "")
|
||||||
|
}
|
||||||
|
assertTrue(repository.transactions.value.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsAmountAtOneMillionDollars() = runTest {
|
||||||
|
val repository = Repository(FakeLedgerStore())
|
||||||
|
val account = repository.createAccount("Checking")
|
||||||
|
|
||||||
|
repository.createTransaction(account.id, TxnType.credit, 100_000_000L, "rent")
|
||||||
|
|
||||||
|
assertEquals(listOf(100_000_000L), repository.transactions.value.map { it.amount })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ sqlite-wasm = "3.53.0-build1"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
|
||||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
|
|||||||
Reference in new issue
Block a user