DEV Community

Cover image for The One Rule That Keeps Every Account Balance in FinLedger Honest
DEVANSHU PATIL
DEVANSHU PATIL

Posted on

The One Rule That Keeps Every Account Balance in FinLedger Honest

The One Rule That Keeps Every Account Balance in FinLedger Honest

In a personal finance application, the most catastrophic bug is not a UI glitch, an unexpected crash, or a missing icon.

The most catastrophic bug is balance drift.

Balance drift occurs when an account balance displayed on the dashboard slowly diverges from the mathematical reality of its underlying transactions. The user has 15,000 rupees in their bank, but the app displays 14,200 rupees.

Once users notice that their account balance does not match their real bank statement, they lose all faith in the application.

Here is how FinLedger solved balance drift by establishing a single, authoritative posting engine in pure Kotlin.

The Architectural Flaw: Scattered Accounting Math

When apps grow quickly, financial calculations tend to scatter across different layers:

  • The TransactionViewModel updates a local state variable when a user creates an expense.
  • The AccountRepository runs an UPDATE accounts SET balance = balance - amount SQL query.
  • The DashboardScreen recalculates net worth by folding over whatever lists are in memory.
  • The AddTransactionScreen applies custom balance deduction logic for transfers.
flowchart TD
    subgraph AntiPattern["The Anti-Pattern: Scattered Business Logic"]
        direction TB
        UI["UI Layer: Local Recalculations"]
        VM["ViewModel: Ad-hoc State Updates"]
        Repo["Repository: Custom SQL Mutations"]
        DB[("Database: Cached Balances")]
        UI -.->|"Discrepancy"| DB
        VM -.->|"Discrepancy"| DB
        Repo -.->|"Discrepancy"| DB
    end

When calculation logic is duplicated across multiple screens and repository methods, edge cases inevitably slip through:

  • What happens if a transaction date is edited into the previous month?
  • What happens if a credit card transaction is deleted after a statement closes?
  • What happens if a transfer source account is changed from checking to savings?

If different code paths handle balance updates differently, cached balances will gradually decouple from historical transactions.

The Posting Function: The Only Source of Truth

To permanently eliminate balance drift, FinLedger enforces a strict architectural rule:

No screen, ViewModel, or repository is allowed to write balance math directly.

Instead, every single financial transaction in FinLedger flows through a single pure Kotlin domain function: Posting.kt:postings(transaction, accounts).

flowchart LR
    subgraph PureDomain["Pure Domain Posting Engine"]
        direction TB
        Tx["Transaction Entity"] --> PostingEngine["Posting.kt:postings()"]
        Accounts["Account Snapshots"] --> PostingEngine
        PostingEngine --> Deltas["Deterministic Balance Deltas"]
        Deltas -->|"Applied Atomically"| DB[("Room SQLite Database")]
    end

A "posting" is a formal double-entry accounting record representing a signed delta to an account balance. The posting function accepts an immutable transaction record and the relevant accounts, and returns the exact list of account balance modifications required.

For example:

  • A generic EXPENSE generates a negative delta to the liquid source account and zero to liabilities.
  • An INCOME generates a positive delta to the liquid destination account.
  • A TRANSFER generates a negative delta to the source account and a positive delta to the destination account.
  • A CREDIT_CARD_PURCHASE leaves liquid bank accounts untouched and generates a positive delta to the credit card liability account.
  • A CREDIT_CARD_PAYMENT generates a negative delta to the liquid bank account and a negative delta to the card liability account.

Because Posting.kt is a pure function with zero Android framework dependencies, it has zero side effects. Given the same transaction and accounts, it produces the exact same balance deltas every single time.

Bulk Replay vs. Incremental Parity

Once the posting engine is defined, two distinct calculation workflows must be supported:

  1. Incremental Updates: When a user creates or edits a single transaction today, the repository applies the posting delta immediately within a single SQLite transaction (withTransaction) to update the touched account balances in milliseconds.
  2. Bulk Replay (Full Recalculation): When an account is reconciled, or when an entire backup database is restored, the engine recalculates all account balances from scratch by starting from each account's initial opening balance and replaying all historical confirmed transactions chronologically through Posting.kt.
flowchart TD
    subgraph ParityVerification["Financial Parity Enforcement"]
        direction TB
        Opening["Account Opening Balance"] --> Replay["Bulk Replay (All Transactions)"]
        Replay --> BalanceA["Recomputed Balance A"]

        Cached["Cached Entity Balance"] --> Incr["Incremental Posting Delta"]
        Incr --> BalanceB["Updated Balance B"]

        BalanceA <-->|"Must Match Exactly (ABS < 0.005)"| BalanceB
    end

In many systems, bulk recalculation and incremental updates produce subtly different results over time due to floating-point rounding or transaction ordering bugs.

In FinLedger's test suite, the PostingParityTest runs hundreds of randomized combinations of transfers, card purchases, refunds, and lending transactions, verifying that incremental updates and full bulk replays produce identical account balances down to the exact paisa.

Lessons Learned

  1. Keep Accounting Math Pure: Keep financial calculations in pure Kotlin domain functions. Do not bury them in database triggers or UI state composables.
  2. Eliminate Parallel Accounting: Have exactly one definition of how money moves. Every screen and worker must delegate to that single definition.
  3. Automate Parity Tests: Continuously test incremental modifications against full historical recalculation to catch balance drift before it reaches users.

FinLedger is a privacy-first, offline-first personal finance application for Android that models bank accounts, credit cards, and cash without double-counting.

Explore FinLedger on Google Play: https://play.google.com/store/apps/details?id=com.devanshufinance.app

Top comments (0)