DEV Community

Cover image for The Never-Guess Rule: How FinLedger Handles Ambiguous Financial SMS Safely
DEVANSHU PATIL
DEVANSHU PATIL

Posted on

The Never-Guess Rule: How FinLedger Handles Ambiguous Financial SMS Safely

The Never-Guess Rule: How FinLedger Handles Ambiguous Financial SMS Safely

In financial software, a silent mistake is far more dangerous than a visible error.

If an application crashes, the user reopens it. If an application displays an error message, the user knows something went wrong. But if an application quietly guesses where money went and assigns a transaction to the wrong bank account or credit card, the ledger enters a corrupted state that can remain unnoticed for months.

When automated transaction detection was integrated into FinLedger, I established a core engineering rule: The Never Guess Principle.

If incoming financial evidence does not point decisively to a single, unambiguous account or category, the system must never make an educated guess. It must safely quarantine the candidate and demand explicit user resolution.

Here is why guessing fails in personal finance, and how FinLedger architected an unresolved staging workflow to preserve mathematical truth.

The Temptation of Heuristic Guessing

Automated SMS detection systems encounter ambiguity constantly. Consider this realistic banking SMS:

"Paid Rs. 2,400.00 to AMAZON INDIA using Card ending 1042 on 28-OCT-26. Avl Limit: Rs. 1,45,000.00."

Now suppose the user has two credit cards configured in their app:

  1. An Amazon Pay ICICI Credit Card with physical card number ending in 1042.
  2. A virtual corporate card on a different bank that also happens to have a tokenized virtual card suffix ending in 1042.

Or consider a transaction where the bank notification says:

"Rs 500.00 transferred from A/c XX4091 to John Doe."

If the user has an HDFC Savings account and an HDFC Salary account that both share the same last four digits (a common scenario when opening secondary accounts at the same branch), which account should be debited?

flowchart TD
    subgraph DangerZone["The Silent Guessing Anti-Pattern"]
        SMS["Incoming SMS with Card ending 1042"] --> GuessEngine["Heuristic Engine:
        'I see two cards ending in 1042.
        I will pick Card #1 because it has more recent activity.'"]
        GuessEngine --> CorruptedLedger["Corrupted Ledger:
        Card #1 balance overstated by Rs 2,400.
        Card #2 statement balance diverges from bank!"]
        CorruptedLedger --> LossOfTrust["User Discovers Discrepancy 2 Months Later:
        Permanent Loss of Trust in Financial Software"]
    end

Many commercial finance apps prioritize "magic automation" over correctness. When faced with multiple candidate accounts, their heuristics pick the most frequently used account, or the one with the highest balance.

The result is catastrophic for a ledger. When the user receives their monthly credit card statement, the balance in their app does not match the bank statement. Tracking down where the divergence started requires auditing hundreds of past entries.

The Staging Architecture: Quarantining Ambiguity

To prevent balance contamination, FinLedger separates transaction detection into two distinct architectural layers:

  1. The Parsing & Resolution Engine (AccountMatcher.kt and AccountResolver.kt).
  2. The Unresolved Staging Vault (UnresolvedSmsEntity.kt and UnresolvedSmsDao.kt).
flowchart TD
    subgraph StagingPipeline["The 'Never Guess' Resolution Pipeline"]
        Raw["Parsed SMS Candidate"] --> Matcher["AccountResolver"]

        Matcher -->|Decisive Match: 1 Account Found| Validated["Validated Transaction Data:
        Ready for Staging as PENDING_CONFIRMATION"]

        Matcher -->|Zero Accounts Found| Unresolved["Unresolved Candidate:
        Flagged as 'No matching account suffix'"]

        Matcher -->|Multiple Accounts Found| Ambiguous["Ambiguous Candidate:
        Flagged as 'Multiple accounts share suffix 1042'"]

        Unresolved --> StagingTable["Unresolved SMS Staging Table:
        Stored in Isolated SQLite Entity.
        Authoritative Account Balances are NOT touched."]

        Ambiguous --> StagingTable

        StagingTable --> ReviewTray["Pending Review UI Pill:
        Surfaces cleanly on Ledger Screen"]

        ReviewTray --> UserDecision["Explicit User Action:
        Select exact account or discard candidate"]

        UserDecision --> LiveLedger["Atomic Insertion into Master Ledger"]
    end

The Three Resolution States

In AccountMatcher.kt, matching candidate evidence against active accounts yields one of three strict states:

  1. ExactMatch: Exactly one active account matches the bank identifier and account or card suffix with high confidence. The transaction moves forward to candidate staging.
  2. AmbiguousMatch: Two or more active accounts share the detected credentials. Instead of guessing based on usage frequency or recency, the engine immediately yields an AmbiguousMatch holding the list of conflicting candidate accounts and the diagnostic reason.
  3. NoMatch / InsufficientEvidence: No active account possesses the detected digits, or the bank notification lacks critical routing details. The engine yields a NoMatch state.

The Isolated SQLite Entity

Ambiguous and unmatched candidates are never discarded, but they are strictly prevented from entering the transactions table.

Instead, they are persisted into a dedicated Room entity named UnresolvedSmsEntity. This table stores:

  • The raw SMS body and originating sender ID.
  • The extracted financial amount and transaction timestamp.
  • The detected counter-party merchant name.
  • The specific reason resolution was halted (such as missing account mapping or conflicting card numbers).
  • The list of prospective candidate account IDs.

Because this table is completely decoupled from the master accounts and transactions tables, authoritative ledger balances remain 100% pure. Carried-forward running balances, net cash flow metrics, and monthly expense aggregates ignore unresolved records entirely.

Surfacing Ambiguity in Jetpack Compose

Quarantining records is half the battle; the user must be given a zero-friction mechanism to resolve them.

On the primary Ledger dashboard, FinLedger observes the count of unresolved candidates via a reactive Kotlin Flow from UnresolvedSmsDao. If unresolved candidates exist:

  1. A discreet, high-contrast review pill appears at the top of the transaction feed, indicating that transactions require confirmation.
  2. Tapping the pill opens the PendingSmsReview modal sheet.
  3. The UI presents the raw SMS text, the extracted amount, and a prompt: "We detected Rs. 2,400.00 at Amazon, but found two accounts ending in 1042. Which one did you use?"
  4. The user taps the correct card with a single gesture.
  5. The staging record is resolved, an atomic database transaction inserts the entry into the master ledger, and the staging record is deleted.

If the SMS was an irrelevant alert, a single swipe dismisses the candidate forever without leaving orphaned rows or corrupted balances.

Why Determinism Builds User Trust

When building tools that manage people's money, automated magic must always yield to determinism.

Users forgive an application that asks for clarification: "I noticed a payment, please tell me which card was used."

Users do not forgive an application that secretly modifies their financial records based on probabilistic assumptions.

By adhering strictly to the Never Guess Principle, FinLedger ensures that convenience never compromises ledger accuracy.


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)