Skip to content

Monetary amounts are stored as f32, which loses cent-level precision #1

Description

@dsegovia90

Hi Abe, first of all, thanks for your work on the crate. I think its the right approach for rust. data-structures only.

Issue Summary

Account::balance, Account::available_balance and Transaction::amount are typed f32.

SimpleFIN transmits these as JSON strings ("balance": "100.23") specifically so they remain exact. deserialize_f32_str parses that string into f32, which discards that exactness before any consumer can preserve it.

The limit is a property of the format. f32 has a 24-bit mantissa, so the gap between adjacent representable values doubles at each power of two:

magnitude gap between adjacent f32 values cents representable?
at 2¹⁶ = 65,536 $0.0078125 yes
at 2¹⁷ = 131,072 $0.015625 no — wider than a cent
at 2¹⁸ = 262,144 $0.03125 no

Above $131,072, two-decimal amounts cannot be represented at all, so any balance past that point is silently altered. That threshold sits inside the normal range for mortgage, investment and retirement account balances.

Reproduction

[dependencies]
simplefin-data = "0.2.0"
serde_json = "1"
use simplefin_data::accountset::AccountSet;

fn roundtrip(v: &str) -> String {
    let json = format!(
        r#"{{"errlist":[],"connections":[],"accounts":[{{"id":"a","name":"n","conn_id":"c","currency":"CAD","balance":"{v}","available-balance":"{v}","balance-date":978366153,"transactions":[]}}]}}"#
    );
    let set: AccountSet = serde_json::from_str(&json).unwrap();
    serde_json::to_value(&set).unwrap()["accounts"][0]["balance"]
        .as_str().unwrap().to_string()
}

fn main() {
    for v in ["99999.99", "123456.78", "131071.99", "131072.01",
              "543210.98", "999999.99", "1234567.89"] {
        println!("{:>12} -> {}", v, roundtrip(v));
    }
}

Output — the string the crate emits differs in value from the string it was given:

    99999.99 -> 99999.99         ok
   123456.78 -> 123456.78        ok
   131071.99 -> 131071.99        ok
   131072.01 -> 131072.02        +1 cent
   543210.98 -> 543211           +2 cents
   999999.99 -> 1000000          +1 cent
  1234567.89 -> 1234567.9        +1 cent

Scanning upward one cent at a time, the first value the crate cannot represent is 131072.01 — exactly 2¹⁷, as the table above predicts.

A consumer reconciling a computed running balance against the reported balance — a common and worthwhile integrity check — will see a permanent mismatch on any account above that threshold, for reasons unrelated to any missing transaction.

Suggested directions

  1. rust_decimal::Decimal (feature-gated if you'd rather not take the dependency
    unconditionally). Parses the wire string exactly, and is what most Rust financial code
    already uses.
  2. Keep the raw String and expose a typed accessor. Zero dependencies, zero loss, and it
    leaves the numeric choice to the consumer.
  3. f64 as a strictly-better stopgap — ~15–17 significant digits covers realistic currency
    values, though it remains binary floating point and so is still not exact for decimal
    fractions.

Option 1 or 2 would make this crate usable for ledger-grade work. Happy to open a PR if you have
a preference on direction.

Environment

  • simplefin-data 0.2.0
  • rustc 1.93.0
  • serde_json 1.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions