mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 03:27:10 +00:00
merge origin/master into llm-recording-and-analysis
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
This commit is contained in:
commit
6e85cac8b3
130 files changed
+12772
-1617
No files matched your search
@@ -37,7 +37,9 @@ IOS_DEVICE="iPhone 15" just ios # pick a different simulator
|
||||
|
||||
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
|
||||
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
|
||||
into the SwiftUI host, installs, and launches.
|
||||
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
|
||||
The uninstall matters: folio's signed-in session survives an install over the
|
||||
top, so without it a run opens on the last run's Home screen.
|
||||
|
||||
## Web
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ kotlin {
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
}
|
||||
|
||||
+5
@@ -3,6 +3,7 @@ package app.folio.feature.ledger
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.TxnType
|
||||
import app.folio.navigation.Navigator
|
||||
@@ -87,6 +88,10 @@ class AddTransactionViewModel(
|
||||
form.update { it.copy(error = "Amount must be greater than zero") }
|
||||
return
|
||||
}
|
||||
if (cents > MAX_TRANSACTION_AMOUNT_CENTS) {
|
||||
form.update { it.copy(error = "Amount is too large (max \$1,000,000.00)") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
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 wholeLong = whole.toLongOrNull() ?: return null
|
||||
val fracLong = fracPadded.toLongOrNull() ?: return null
|
||||
val total = wholeLong * 100 + fracLong
|
||||
if (total < 0) return null
|
||||
return total
|
||||
if (wholeLong > (Long.MAX_VALUE - fracLong) / 100) return null
|
||||
return wholeLong * 100 + fracLong
|
||||
}
|
||||
|
||||
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.async.extensions)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
const val MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000L
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
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 {
|
||||
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" }
|
||||
val txn = Transaction(
|
||||
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]
|
||||
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" }
|
||||
|
||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||
|
||||
@@ -78,6 +78,13 @@ _ensure-device:
|
||||
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
|
||||
exit 1
|
||||
|
||||
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
|
||||
test-unit:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
./gradlew :core:testDebugUnitTest :app:shared:testDebugUnitTest
|
||||
|
||||
# Build the folio debug APK without installing it.
|
||||
build:
|
||||
#!/usr/bin/env bash
|
||||
@@ -130,6 +137,10 @@ ios:
|
||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
||||
-derivedDataPath app/iosApp/build \
|
||||
build | tail -5
|
||||
# Installing over the top keeps the data container, and folio's signed-in
|
||||
# session with it, so a run started straight after would open on the last
|
||||
# run's Home screen instead of Login and diverge at step 1.
|
||||
xcrun simctl uninstall booted app.folio || true
|
||||
xcrun simctl install booted "{{ios_app}}"
|
||||
xcrun simctl launch booted app.folio
|
||||
|
||||
|
||||
@@ -106,6 +106,21 @@ export function readHomeTotalBalance(args: {
|
||||
//
|
||||
// Callers pass null for a reading they could not take, so an empty list and an
|
||||
// empty map are one case here rather than two.
|
||||
//
|
||||
// A non-empty list is trusted as current, and that rests on the app's shape
|
||||
// rather than on anything in the frame. `fresh` also resets the submit window,
|
||||
// so a card list drawn before the store caught up with a commit would bank
|
||||
// stale counts, start the next window empty, and leave the rise arriving with
|
||||
// no budget to cover it: a healthy app convicted of a double submit. Folio
|
||||
// cannot serve that frame. AddTransactionViewModel.submit pops ONE entry, so a
|
||||
// commit lands back on the ledger it came from and the first Home reading is a
|
||||
// whole action and settle later. Two pops do reach Home, but two pops means two
|
||||
// Submit events, which is the double submit itself, and a verdict there is
|
||||
// late rather than wrong. Measured over four recorded android runs: 51
|
||||
// commit-capable single taps landed on the ledger or the transaction screen and
|
||||
// none on Home, 49 of 49 commits already showed their new balance in the frame
|
||||
// read at the same step, and no rise ever arrived against an empty budget in
|
||||
// 1303 steps. A submit that navigated straight to Home would reopen this.
|
||||
export interface HomeCardReading<T> {
|
||||
value: T | null;
|
||||
carrier: T | null;
|
||||
@@ -123,10 +138,68 @@ export function readHomeCards<T>(args: {
|
||||
return { value: reading, carrier: reading, fresh: true };
|
||||
}
|
||||
|
||||
function isTapOn(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
target: string,
|
||||
): boolean {
|
||||
// The balance of the ONE account the ledger and the add-transaction screen are
|
||||
// showing, and the window it closes.
|
||||
//
|
||||
// It exists because the Home readings above close their window only when the
|
||||
// walk goes back to Home, and a walk inside the transaction flow does not: a
|
||||
// submit pops back to the ledger it came from, so the fuzzer can add
|
||||
// transactions all day without Home ever being redrawn. The iOS run in #78 went
|
||||
// 117 steps between two Home readings and accumulated 37 submits against a rise
|
||||
// of 15 transactions, which is no evidence about any single action. Both
|
||||
// screens here carry the account's own balance (LedgerBalance,
|
||||
// TxnCurrentBalance), so the window between two readings holds one action.
|
||||
//
|
||||
// WHICH account is never asked, because inside a run of these two routes it
|
||||
// cannot change. Route.Ledger is pushed only by tapping a card on Home,
|
||||
// Route.AddTransaction only by the ledger's own button for its own account, and
|
||||
// an accepted submit pops back to that same ledger. Reaching another account's
|
||||
// ledger means passing through Home, and one action reads one frame, so a frame
|
||||
// that is neither of these two routes always sits in between. Dropping the
|
||||
// carrier on every such frame, transition frames included, is what makes the
|
||||
// two compared numbers two readings of one account.
|
||||
export interface AccountBalanceReading {
|
||||
value: number | null;
|
||||
carrier: number | null;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
function showsOneAccount(route: string | null): boolean {
|
||||
return route === "ledger" || route === "add-transaction";
|
||||
}
|
||||
|
||||
export function readAccountBalance(args: {
|
||||
route: string | null;
|
||||
balanceText: string | undefined;
|
||||
previousCarrier: number | null;
|
||||
}): AccountBalanceReading {
|
||||
const { route, balanceText, previousCarrier } = args;
|
||||
if (!showsOneAccount(route)) return { value: null, carrier: null, fresh: false };
|
||||
const balance = parseAccountBalance(balanceText);
|
||||
// Unreadable is unknown, not a new value: the balance node scrolls off the
|
||||
// viewport like anything else. The account still cannot have changed, so the
|
||||
// last number we read is carried across and the window stays open.
|
||||
if (balance === null) return { value: previousCarrier, carrier: previousCarrier, fresh: false };
|
||||
return { value: balance, carrier: balance, fresh: true };
|
||||
}
|
||||
|
||||
// state.lastAction as the two hosts build it (internal/verifier/marshal.go
|
||||
// lastActionFields), read defensively: every field is what a Go struct decided
|
||||
// to emit, not something this file can trust a compile-time shape for.
|
||||
//
|
||||
// `applied` is true when the runner saw the action's dispatch succeed and null
|
||||
// when the apply call failed with the gesture possibly already delivered: an
|
||||
// RPC deadline can fire after the tap landed, and nothing can find out
|
||||
// afterwards. That is unknown, not "it did not happen", which is what
|
||||
// `lastAction === null` says.
|
||||
export interface ObservedAction {
|
||||
kind?: string;
|
||||
on?: string | object;
|
||||
applied?: true | null;
|
||||
relaunched?: true | null;
|
||||
}
|
||||
|
||||
function isTapOn(lastAction: ObservedAction | null, target: string): boolean {
|
||||
if (lastAction == null) return false;
|
||||
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false;
|
||||
const on = lastAction.on;
|
||||
@@ -138,19 +211,40 @@ function isTapOn(
|
||||
// Repository.createTransaction: AddTransactionViewModel.submit() is the only
|
||||
// caller, AddTransactionEvent.Submit is the only thing that runs it, and the
|
||||
// TxnSubmit button's onClick is the only thing that sends that event.
|
||||
export function isTxnSubmitTap(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
): boolean {
|
||||
export function isTxnSubmitTap(lastAction: ObservedAction | null): boolean {
|
||||
return isTapOn(lastAction, "TxnSubmit");
|
||||
}
|
||||
|
||||
// Likewise the only action that creates an account.
|
||||
export function isAddAccountSubmitTap(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
): boolean {
|
||||
export function isAddAccountSubmitTap(lastAction: ObservedAction | null): boolean {
|
||||
return isTapOn(lastAction, "AddAccountSubmit");
|
||||
}
|
||||
|
||||
// Did the runner see this action's dispatch succeed? `applied` is null when the
|
||||
// apply call failed with the gesture possibly already delivered (an RPC
|
||||
// deadline can fire after the tap landed), and the runner has no way to find
|
||||
// out afterwards. Such an action may have caused anything the next reading
|
||||
// shows, so it counts toward how many submits a window COULD hold, but it never
|
||||
// licenses attributing an effect to it: a property that demands the effect of
|
||||
// an action that may never have run convicts the app of the runner's own
|
||||
// uncertainty.
|
||||
export function confirmedApplied(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.applied === true;
|
||||
}
|
||||
|
||||
// The runner reports this when its foreground guard had to relaunch the app
|
||||
// after the action. The action still happened, so it still counts toward how
|
||||
// many submits a window could hold; what nobody can promise across it is that
|
||||
// the process survived long enough to commit, or that Home is showing the same
|
||||
// slice of the account list it was.
|
||||
//
|
||||
// `true | null` for the same reason `applied` is: web and iOS cannot read the
|
||||
// foreground at all, so "no relaunch reported" is not "the app never
|
||||
// restarted", and only an explicit true licenses declining.
|
||||
export function acrossRelaunch(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.relaunched === true;
|
||||
}
|
||||
|
||||
// Counts the submit actions inside the window the balance property compares
|
||||
// over: from the last Home total we read to this step, inclusive of this step's
|
||||
// action.
|
||||
@@ -164,16 +258,68 @@ export function isAddAccountSubmitTap(
|
||||
//
|
||||
// The reset lands on `fresh`, the same event that advances the carrier, so the
|
||||
// count always describes exactly the interval the two compared totals span.
|
||||
//
|
||||
// A submit whose dispatch the runner could not confirm counts here, because
|
||||
// this number is an upper bound on the submits the window holds and the tap may
|
||||
// well have landed. Leaving it out is what convicted a healthy app:
|
||||
// committedTransactionsExceedSubmits saw a transaction rise of one against a
|
||||
// window of zero and called it a double submit.
|
||||
//
|
||||
// A submit the app must have refused does not count, for the mirror reason: it
|
||||
// cannot have committed anything, so the bound it would raise is slack the app
|
||||
// can hide a real double submit behind. See submitCouldCommit for what "must
|
||||
// have refused" is allowed to mean.
|
||||
export function countSubmitsInWindow(args: {
|
||||
previousCount: number;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
amountText?: string;
|
||||
fresh: boolean;
|
||||
}): { reported: number; next: number } {
|
||||
const { previousCount, lastAction, fresh } = args;
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) ? 1 : 0);
|
||||
// A relaunch is the one thing that can put a form state on screen other than
|
||||
// the one the tap read, so the field it draws proves nothing about it.
|
||||
const refused = !acrossRelaunch(lastAction) && !submitCouldCommit(args.amountText);
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) && !refused ? 1 : 0);
|
||||
return { reported, next: fresh ? 0 : reported };
|
||||
}
|
||||
|
||||
// Folio's own cap, in cents (core/data/Repository.kt).
|
||||
const MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000;
|
||||
|
||||
// Could the app have committed anything for that submit? The amount field as
|
||||
// the LANDING frame shows it is the form state the tap read: the tap changes
|
||||
// nothing about it, and one action runs per step, so nothing else could have.
|
||||
// Off the transaction screen there is no field to read, and undefined is
|
||||
// unknown, which counts.
|
||||
//
|
||||
// False only where Folio's own code must have refused. parseCents takes
|
||||
// `^\d+(\.\d{1,2})?$` with commas stripped and refuses everything else, and
|
||||
// AddTransactionViewModel refuses a parsed zero on top of that. An empty field
|
||||
// never even reaches the parser: TxnSubmit is
|
||||
// clickable(enabled = amount.isNotBlank()), so the click does not fire.
|
||||
//
|
||||
// This is the difference between a bound and a useless one. The window is an
|
||||
// upper bound on the transactions the interval could hold, and a bound inflated
|
||||
// by taps that commit nothing is a bound the app can never exceed: the iOS run
|
||||
// in #78 read a rise of 15 transactions against a window of 37 submits and had
|
||||
// nothing to say. Measured over four recorded android runs, 19, 11, 25 and 25
|
||||
// of 35, 26, 42 and 42 submit taps landed with the amount field empty.
|
||||
//
|
||||
// An amount over Folio's cap is refused before any coroutine starts
|
||||
// (MAX_TRANSACTION_AMOUNT_CENTS, checked in both AddTransactionViewModel.submit
|
||||
// and Repository.createTransaction), and the fuzzer's corpus reaches the button
|
||||
// with one: "999999999999999999999" passes AMOUNT_REGEX, so the field takes it.
|
||||
// Float is precise enough to say which side of the cap an amount is on. The cap
|
||||
// is 1e8, every integer cent up to 2^53 is exact, and an amount far enough above
|
||||
// it to be inexact is far enough above it to be refused.
|
||||
export function submitCouldCommit(amountText: string | undefined): boolean {
|
||||
if (amountText === undefined) return true;
|
||||
const trimmed = amountText.trim().replace(/,/g, "");
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false;
|
||||
if (!/[1-9]/.test(trimmed)) return false;
|
||||
return Number(trimmed) * 100 <= MAX_TRANSACTION_AMOUNT_CENTS;
|
||||
}
|
||||
|
||||
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to
|
||||
// integer cents. Anything that is not a complete amount is null, not 0: a
|
||||
// balance we could not read is unknown, and reading it as zero silently moves
|
||||
@@ -214,6 +360,15 @@ export function cardBalanceText(args: {
|
||||
return match ? match[0].trim() : undefined;
|
||||
}
|
||||
|
||||
// One account's own balance, off the node whose whole text it is: bare on the
|
||||
// ledger ("$196.00"), labelled in the add-transaction header
|
||||
// ("Balance: $196.00"). Anchored at the end for the same reason as above, so
|
||||
// the label cannot be read as part of the amount.
|
||||
export function parseAccountBalance(text: string | undefined): number | null {
|
||||
const match = text?.match(TRAILING_BALANCE);
|
||||
return match ? parseDollarCents(match[0].trim()) : null;
|
||||
}
|
||||
|
||||
// The result is an identity key, not a display name: off web it is the
|
||||
// AccountName text, on web it is whatever the merged card text leaves in front
|
||||
// of the count label, initials and all ("T2Travel" for "Travel 2024"). Its only
|
||||
@@ -233,6 +388,24 @@ export function cardAccountName(args: {
|
||||
return head.slice(0, label.index).trim();
|
||||
}
|
||||
|
||||
// The avatar text that opens a merged card's identity key, mirroring Folio's
|
||||
// initialsOf (app/shared/.../util/Format.kt).
|
||||
//
|
||||
// A mirror because the alternative is a suffix test, and a suffix test cannot
|
||||
// say which card a name belongs to. Drift can only cost a detection: the result
|
||||
// is compared whole against a card's key, so initials that stop matching the
|
||||
// app match no card rather than the wrong one.
|
||||
export function initialsOf(name: string): string {
|
||||
// Java's \s, which is what Kotlin's Regex("\\s+") compiles to. JS's \s also
|
||||
// matches the unicode spaces, and would split names the app keeps whole.
|
||||
const parts = name.trim().split(/[ \t\n\v\f\r]+/).filter(part => part !== "");
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
if (first === undefined || last === undefined) return "?";
|
||||
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
||||
return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
// One card's transaction count, in the strongest form its SOURCE supports. The
|
||||
// two forms are the whole reason this is not just a number:
|
||||
//
|
||||
@@ -305,13 +478,26 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null
|
||||
//
|
||||
// A name carried by more than one card is left out for the same reason, the
|
||||
// rule createdAccountHasNonZeroBalance applies with `matches.length === 1`:
|
||||
// nothing here can say which of them a count came from. Folio accepts the same
|
||||
// account name twice and Home lists whatever fits the viewport, so a reading
|
||||
// that saw one Travel card and a later one that saw two would otherwise
|
||||
// subtract two DIFFERENT accounts' counts and convict a healthy app of
|
||||
// double-submitting. The twin does not have to be readable to spoil the
|
||||
// identity, so duplicates are counted over every card, not just the usable
|
||||
// ones. Dropping a card can only ever cost a detection.
|
||||
// nothing here can say which of them a count came from. Two accounts never
|
||||
// share a NAME (Accounts.name is UNIQUE and Repository.createAccount rejects
|
||||
// one already taken, NOCASE), but they can share a KEY, because web's key is
|
||||
// the card text in front of the digit run, which is the name with any trailing
|
||||
// digits shaved off it: "Travel1" holding 25 transactions and "Travel12"
|
||||
// holding 6 both key to "TRTravel" and both read a three-digit run, so
|
||||
// subtracting one from the other subtracts two unrelated counting series. The
|
||||
// twin does not have to be readable to spoil the identity, so duplicates are
|
||||
// counted over every card, not just the usable ones. Dropping a card can only
|
||||
// ever cost a detection.
|
||||
//
|
||||
// What this cannot see is a twin that never shares a reading with its pair, and
|
||||
// Home lists only what fits the viewport. Nothing computed from the card text
|
||||
// can: the two cards' text is identical character for character ("TRTravel1"
|
||||
// followed by "25 transactions" and "TRTravel12" followed by "6 transactions"
|
||||
// are one string), so no key derived from it separates them. Refusing every run
|
||||
// that could hide a name's own digits would, at the price of the evidence web
|
||||
// convicts on today, whose measured witness is a count of 12 rising to 14.
|
||||
// Separating them needs something the tree does not carry: the account's id on
|
||||
// the card, or a separator in front of the count.
|
||||
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, TxnCount> | null {
|
||||
const cardsPerName = new Map<string, number>();
|
||||
for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1);
|
||||
@@ -343,7 +529,7 @@ export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, T
|
||||
// same as fine, and both come back false here.
|
||||
export function createdAccountHasNonZeroBalance(args: {
|
||||
route: string | null;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
typedName: string | undefined;
|
||||
before: Account[] | null;
|
||||
after: Account[] | null;
|
||||
@@ -351,15 +537,34 @@ export function createdAccountHasNonZeroBalance(args: {
|
||||
const { route, lastAction, before, after } = args;
|
||||
if (route !== "home") return false;
|
||||
if (!isAddAccountSubmitTap(lastAction)) return false;
|
||||
// A creation nobody can confirm happened is not a creation to attribute a
|
||||
// card to: the card that turned up may be an older account of the same name
|
||||
// scrolling into view.
|
||||
if (!confirmedApplied(lastAction)) return false;
|
||||
// A relaunch draws Home from the top again, so the card that carries the
|
||||
// typed name may be an older account of that name laid out where the new one
|
||||
// used to be, and the create may not have reached sqlite at all.
|
||||
if (acrossRelaunch(lastAction)) return false;
|
||||
if (before === null || after === null) return false;
|
||||
const typed = (args.typedName ?? "").trim();
|
||||
if (typed === "") return false;
|
||||
// Web merges the card into one node whose text opens with the avatar
|
||||
// initials, so the identity key is "INInvestments" where android and iOS give
|
||||
// "Investments"; endsWith covers both. Two cards answering to the same typed
|
||||
// name (a second "Travel", or a card the tree exposed twice) leave the
|
||||
// appearance unattributable, so nothing is judged.
|
||||
const matches = after.filter(account => account.name.endsWith(typed));
|
||||
// "Investments". Both forms are built from the name that was typed and
|
||||
// compared whole. A suffix test covered both too, and it also let any OTHER
|
||||
// account ending in those letters answer for the created one: type "Fund"
|
||||
// next to an existing "Emergency Fund", have the new card clipped out of the
|
||||
// reading the way Home clips any card, and the old account is convicted for
|
||||
// money it has held all along. It cost detections as well, because a typed
|
||||
// name that two cards end with is judged as unattributable rather than as the
|
||||
// one card that carries it.
|
||||
//
|
||||
// Two cards answering to one key stay unattributable: Accounts.name is UNIQUE
|
||||
// and Repository.createAccount rejects a name already taken, so that pair is
|
||||
// a card the tree exposed twice, or two names the merged key cannot tell
|
||||
// apart.
|
||||
const mergedKey = initialsOf(typed) + typed;
|
||||
const matches = after.filter(account => account.name === typed || account.name === mergedKey);
|
||||
const created = matches.length === 1 ? matches[0] : undefined;
|
||||
if (created === undefined) return false;
|
||||
if (before.some(account => account.name === created.name)) return false;
|
||||
@@ -401,6 +606,70 @@ export function committedTransactionsExceedSubmits(args: {
|
||||
return committed > submitsInWindow;
|
||||
}
|
||||
|
||||
// The same rule as above, measured in money over the account's own window: one
|
||||
// submit action can commit one transaction, so the account's balance cannot
|
||||
// move by more than the amount that submit typed.
|
||||
//
|
||||
// An UPPER BOUND, the same one submitChangesBalanceByAtMostTypedAmount applies
|
||||
// to Home's total, and that is what makes a one-action window safe. A balance
|
||||
// that has not moved is a commit still in flight (createTransaction runs in a
|
||||
// coroutine), a submit the app rejected, or a tap that never landed, and none
|
||||
// of those is evidence of anything; an equality would convict all three. Moving
|
||||
// by MORE than one submit's worth is not something a correct app can do: only
|
||||
// createTransaction moves this number, only a TxnSubmit tap reaches it, and the
|
||||
// window holds exactly one such tap.
|
||||
//
|
||||
// What it can convict, and what it cannot. The double tap sends two Submit
|
||||
// events, and where they land decides who judges them. Two commits and two pops
|
||||
// reach Home, where this reads no balance at all and
|
||||
// committedTransactionsExceedSubmits does the convicting: that is the shape the
|
||||
// recorded iOS run at runs/folio-ios/20260815-102711 produced, three double taps
|
||||
// out of three, all on Home. Two commits with the second pop cancelled by the
|
||||
// first stop on the account's own ledger, and only this sees them. So this is
|
||||
// not the check that fixed #78, and widening its route gate would not make it
|
||||
// one: readAccountBalance drops the carrier off these two screens, so a Home
|
||||
// landing has nothing to compare.
|
||||
//
|
||||
// It is not idle either. Over that same run it judged 18 of 240 steps against
|
||||
// real readings, every one a submit landing back on the ledger with the balance
|
||||
// moved by exactly what was typed. A commit for more than the amount typed, on
|
||||
// any of those 18, had nowhere else to be caught: the total-balance form got
|
||||
// past its own gates on one step in the whole run.
|
||||
//
|
||||
// A submit the runner could not confirm needs no case of its own: if it never
|
||||
// landed the balance did not move, which is under the bound.
|
||||
// countSubmitsInWindow counts it either way, so it cannot smuggle a second
|
||||
// commit into a window that looks like one.
|
||||
//
|
||||
// typedAmount is the amount the app parsed for THIS submit (parseTypedAmount
|
||||
// mirrors parseCents), so every rejected amount and every amount too large to
|
||||
// hold exactly arrives here as 0 and is vacuous.
|
||||
//
|
||||
// The float guards are the ones submitChangesBalanceByAtMostTypedAmount explains:
|
||||
// each balance and the typed amount lose precision on their own past
|
||||
// Number.MAX_SAFE_INTEGER. Their difference needs none, because a difference
|
||||
// that is really within a safe typedAmount is itself safe and comes out exact.
|
||||
export function committedAmountExceedsOneSubmit(args: {
|
||||
route: string | null;
|
||||
lastAction: ObservedAction | null;
|
||||
submitsInWindow: number;
|
||||
typedAmount: number;
|
||||
prevAccountBalance: number | null;
|
||||
currAccountBalance: number | null;
|
||||
}): boolean {
|
||||
const { route, lastAction, submitsInWindow, typedAmount } = args;
|
||||
const { prevAccountBalance, currAccountBalance } = args;
|
||||
if (!showsOneAccount(route)) return false;
|
||||
if (!isTxnSubmitTap(lastAction)) return false;
|
||||
if (submitsInWindow !== 1) return false;
|
||||
if (typedAmount <= 0) return false;
|
||||
if (prevAccountBalance === null || currAccountBalance === null) return false;
|
||||
if (!Number.isSafeInteger(prevAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(currAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(typedAmount)) return false;
|
||||
return Math.abs(currAccountBalance - prevAccountBalance) > typedAmount;
|
||||
}
|
||||
|
||||
// How far one account's count rose between two readings, or null when the pair
|
||||
// is not comparable. Not comparable is not zero: the account drops out of the
|
||||
// sum entirely, which can only cost a detection.
|
||||
@@ -448,12 +717,27 @@ export function parseTypedAmount(text: string | undefined | null): number {
|
||||
}
|
||||
|
||||
// When the last action is a tap (or double-tap) on the transaction Submit
|
||||
// button, the absolute change in total balance must equal the amount the
|
||||
// user typed. A double-submit lands two transactions and shifts the balance
|
||||
// by 2x the typed amount, tripping this check. The route gate skips steps
|
||||
// whose landing screen is not Home: totalBalance is only freshly read from
|
||||
// Home's own TOTAL BALANCE node, so off-Home comparisons would read a stale
|
||||
// carrier value and false-fire.
|
||||
// button, the absolute change in total balance cannot EXCEED the amount the
|
||||
// user typed. A double-submit lands two transactions and shifts the balance by
|
||||
// 2x the typed amount, tripping this check. The route gate skips steps whose
|
||||
// landing screen is not Home: totalBalance is only freshly read from Home's own
|
||||
// TOTAL BALANCE node, so off-Home comparisons would read a stale carrier value
|
||||
// and false-fire.
|
||||
//
|
||||
// A bound rather than the equality this used to be, and the same bound
|
||||
// committedAmountExceedsOneSubmit applies to the account's own balance. The
|
||||
// write finishes before AddTransactionViewModel navigates, but nothing
|
||||
// establishes that Home's total has re-rendered before the frame is read: the
|
||||
// store's flow re-emits on its own schedule. A total that has not caught up has
|
||||
// not moved at all, and an equality convicts a healthy app for it.
|
||||
//
|
||||
// The cost is real and is not covered anywhere else in this spec: a balance
|
||||
// that moves by LESS than the amount typed, a transaction silently dropped or
|
||||
// committed for the wrong amount, is a bug this no longer judges. It cannot be
|
||||
// told apart from a total one frame behind, and a check that fires on both is
|
||||
// evidence about neither. What it keeps is the bug it exists for: every one of
|
||||
// the four recorded android convictions is a 6400 move against 3200 typed, and
|
||||
// 2x still exceeds x.
|
||||
//
|
||||
// submitsInWindow is what keeps the comparison honest. prevTotalBalance is the
|
||||
// last total we READ, not the total as of the previous transaction, so the two
|
||||
@@ -463,9 +747,9 @@ export function parseTypedAmount(text: string | undefined | null): number {
|
||||
// evidence about the amount typed into any one submit, so anything other than
|
||||
// exactly one submit action in the window is vacuous. Exactly one still catches
|
||||
// the bug: the double-tap is a single action.
|
||||
export function submitChangesBalanceByTypedAmount(args: {
|
||||
export function submitChangesBalanceByAtMostTypedAmount(args: {
|
||||
route: string | null;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
submitsInWindow: number;
|
||||
typedAmount: number;
|
||||
prevTotalBalance: number | null;
|
||||
@@ -476,6 +760,20 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
|
||||
if (route !== "home") return true;
|
||||
if (!isTxnSubmitTap(lastAction)) return true;
|
||||
// The two totals were read from two different processes. SqlLedgerStore
|
||||
// starts each one on stateIn(Eagerly, emptyList()) and HomeScreen composes
|
||||
// formatCents(total) off whatever the flow holds, so a restarted app draws
|
||||
// $0.00 into TotalBalance until sqlite answers, and that number is as far
|
||||
// from the last one as the accounts are rich. There is no submit anywhere
|
||||
// that explains it.
|
||||
//
|
||||
// A submit the runner could not confirm needs no guard of its own: it may
|
||||
// have committed nothing, and a balance that did not move is under any bound.
|
||||
// countSubmitsInWindow counts it exactly like a confirmed one, so a total
|
||||
// that moved by more than one typed amount is the same double commit either
|
||||
// way. Under the equality this used to be, that case had to be excused; a
|
||||
// guard for it here now only drops the convictions it exists to make.
|
||||
if (acrossRelaunch(lastAction)) return true;
|
||||
if (submitsInWindow !== 1) return true;
|
||||
if (typedAmount === 0) return true;
|
||||
// An unknown total on either side is not evidence of anything. Comparing one
|
||||
@@ -486,7 +784,7 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
// transaction at whatever fits a Kotlin Long, so a balance of ~1e18 cents is
|
||||
// one accepted amount away, and up there the gap between representable
|
||||
// values is 128 cents: a real 1600-cent move reads back as something else
|
||||
// entirely. The equality below is then false for a healthy single submit
|
||||
// entirely. The comparison below is then false for a healthy single submit
|
||||
// exactly as readily as for a double one, and a check that cannot pass is not
|
||||
// a check that failed.
|
||||
//
|
||||
@@ -501,5 +799,5 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
if (!Number.isSafeInteger(prevTotalBalance)) return true;
|
||||
if (!Number.isSafeInteger(currTotalBalance)) return true;
|
||||
if (!Number.isSafeInteger(typedAmount)) return true;
|
||||
return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount;
|
||||
return Math.abs(currTotalBalance - prevTotalBalance) <= typedAmount;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
cardAccountName,
|
||||
cardBalanceText,
|
||||
cardTxnCount,
|
||||
committedAmountExceedsOneSubmit,
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
createdAccountHasNonZeroBalance,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
oncePerFrame,
|
||||
parseDollarCents,
|
||||
parseTypedAmount,
|
||||
readAccountBalance,
|
||||
readHomeCards,
|
||||
readHomeTotalBalance,
|
||||
routeOfFrame,
|
||||
submitChangesBalanceByTypedAmount,
|
||||
submitChangesBalanceByAtMostTypedAmount,
|
||||
} from "./predicates";
|
||||
import type { Account, CardReading, TxnCount } from "./predicates";
|
||||
|
||||
@@ -100,6 +102,11 @@ const homeCards = oncePerFrame((s: State): CardReading[] =>
|
||||
// the last-read Home total so `previous` and `current` stay on the same scale.
|
||||
const homeTotalText = (s: State) => on("home", "TotalBalance")(s)?.text;
|
||||
|
||||
// The amount field as the frame a submit landed on shows it, which is the form
|
||||
// state that submit read. Every window below asks, because a submit the app
|
||||
// must have refused raises no bound: see submitCouldCommit.
|
||||
const txnAmountText = oncePerFrame((s: State) => on("add-transaction", "TxnAmountField")(s)?.text);
|
||||
|
||||
let lastHomeTotal: number | null = null;
|
||||
const totalBalance = extract<number | null>("totalBalance", s => {
|
||||
const reading = readHomeTotalBalance({
|
||||
@@ -125,6 +132,7 @@ const submitsInWindow = extract("submitsInWindow", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeTotal,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeTotal = window.next;
|
||||
@@ -173,12 +181,52 @@ const submitsSinceCounts = extract("submitsSinceCounts", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeCards,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeCards = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
// The account's own balance, off whichever of its two screens is up. The routes
|
||||
// are exclusive, so at most one of these resolves and the reading is always one
|
||||
// account's number. Its carrier is dropped on every other route, which is what
|
||||
// keeps two readings from spanning two accounts: see readAccountBalance.
|
||||
const accountBalanceText = (s: State) =>
|
||||
on("ledger", "LedgerBalance")(s)?.text ?? on("add-transaction", "TxnCurrentBalance")(s)?.text;
|
||||
|
||||
let lastAccountBalance: number | null = null;
|
||||
const accountBalance = extract<number | null>("accountBalance", s => {
|
||||
const reading = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: lastAccountBalance,
|
||||
});
|
||||
lastAccountBalance = reading.carrier;
|
||||
return reading.value;
|
||||
});
|
||||
|
||||
// A third window, for the same reason the counting invariant has its own: it
|
||||
// closes on this reading's freshness, which is a different event again. The
|
||||
// transaction flow redraws this balance on nearly every frame, so this window
|
||||
// is the narrow one, usually a single action wide.
|
||||
let submitsSinceAccountBalance = 0;
|
||||
const submitsSinceBalance = extract("submitsSinceAccountBalance", s => {
|
||||
const fresh = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: null,
|
||||
}).fresh;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceAccountBalance,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceAccountBalance = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
const lastAction = extract("lastAction", s => s.lastAction);
|
||||
|
||||
const loginEmailField = extract("loginEmailField", on("login", "LoginEmail"));
|
||||
@@ -208,13 +256,18 @@ const newAccountBalanceIsZero = always(
|
||||
),
|
||||
);
|
||||
|
||||
// Property 2: a tap on TxnSubmit must move the total balance by exactly the
|
||||
// typed amount. A double-submit lands two transactions, so the balance shifts
|
||||
// by twice the typed amount and the check fires. The route gate inside the
|
||||
// Property 2: a tap on TxnSubmit cannot move the total balance by more than the
|
||||
// typed amount. A double-submit lands two transactions, so the balance shifts by
|
||||
// twice the typed amount and the check fires. The route gate inside the
|
||||
// predicate skips off-Home landings where totalBalance.current is the carrier.
|
||||
const submitMovesBalanceByTypedAmount = always(
|
||||
//
|
||||
// The name is the property's, and the gate keys on it, so it stays; what it
|
||||
// demands is the bound, for the reason submitChangesBalanceByAtMostTypedAmount gives:
|
||||
// a total that has not re-rendered yet has not moved, and an equality convicts a
|
||||
// healthy app for a frame that has not caught up.
|
||||
const submitMovesBalanceByAtMostTypedAmount = always(
|
||||
next(() =>
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: route.current,
|
||||
lastAction: lastAction.current,
|
||||
submitsInWindow: submitsInWindow.current,
|
||||
@@ -230,13 +283,39 @@ const submitMovesBalanceByTypedAmount = always(
|
||||
// stays sound however wide the window between two Home readings gets, because
|
||||
// both sides of the comparison accumulate over the same window. It is the
|
||||
// double-submit stated directly: one tap, two rows.
|
||||
//
|
||||
// One rule, two windows, and they judge different frames rather than the same
|
||||
// frame twice. The counting form compares two Home readings, which is where a
|
||||
// double tap lands: submit() pops one entry per commit, so two commits walk the
|
||||
// stack back past the ledger to Home. What used to defeat it there was the
|
||||
// width of the window, not the window's screen, and what fixed it is
|
||||
// submitCouldCommit refusing to count submits the app cannot have accepted.
|
||||
// Replaying the iOS run at runs/folio-ios/20260815-102711 through this file
|
||||
// measures it: the three double taps sit in windows of 2, 1 and 2 submits with
|
||||
// that rule and 5, 4 and 7 without, and the run convicts 4 times with it and 0
|
||||
// times without.
|
||||
//
|
||||
// The second form says the same thing in money about the one account whose
|
||||
// screen the walk is on, and that window is usually a single action. It judges
|
||||
// the frames the counting form cannot see between two Home visits, and catches
|
||||
// the double submit whose second pop never ran: see
|
||||
// committedAmountExceedsOneSubmit.
|
||||
const submitCommitsOneTransactionPerAction = always(
|
||||
next(() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCounts.previous ?? null,
|
||||
countsAfter: homeTxnCounts.current,
|
||||
submitsInWindow: submitsSinceCounts.current,
|
||||
}),
|
||||
next(
|
||||
() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCounts.previous ?? null,
|
||||
countsAfter: homeTxnCounts.current,
|
||||
submitsInWindow: submitsSinceCounts.current,
|
||||
}) &&
|
||||
!committedAmountExceedsOneSubmit({
|
||||
route: route.current,
|
||||
lastAction: lastAction.current,
|
||||
submitsInWindow: submitsSinceBalance.current,
|
||||
typedAmount: parseTypedAmount(txnAmountField.previous?.text),
|
||||
prevAccountBalance: accountBalance.previous ?? null,
|
||||
currAccountBalance: accountBalance.current,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -303,7 +382,7 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
||||
|
||||
export const properties = {
|
||||
newAccountBalanceIsZero,
|
||||
submitMovesBalanceByTypedAmount,
|
||||
submitMovesBalanceByAtMostTypedAmount,
|
||||
submitCommitsOneTransactionPerAction,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user