Follow

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use
Contact

Why is 4.99 saved as 4.98?

I might have found a glitch in the matrix. A client reported a bug: they set a product price of 4.99 through the backend and said it saved as 4.98.

I suspected that they’ve mistyped the value so I tested it myself and indeed the value came up as 4.98 instead of 4.99. All other product prices seem unaffected. Bunch of 0.99s, 1.99s and 19.99s. I thought we might have introduced this bug recently, as this is the first and only time the client reported seeing such an issue.

To give you some more context, it’s a React UI that uses apollo graphql to shove data to the backend which is Rust (actix). The backend then saves whatever needs saving in the database and the product price gets send to stripe through their API. The 4.98 is in stripe. The way the stripe API accepts prices is in unit amount, which is in cents, not dollars. So 4.99 should land in stripe as 499. According to their dashboard our backend sent 498. Wat?!

MEDevel.com: Open-source for Healthcare and Education

Collecting and validating open-source software for healthcare, education, enterprise, development, medical imaging, medical records, and digital pathology.

Visit Medevel

few moments later

I couldn’t narrow down anything obviously wrong with the code. The graphql mutation sends it as float value 4.99, it saves in our database as FLOAT(4,2) again as 4.99, but the price ends up as 4.98 in stripe.

The way we prepare the value for the stripe API is:

let unit_amount = (product_price_row.price * 100f32) as i64;

Where product_price_row is the SeaORM row and price holds 4.99.

In this case unit_amount ends up as 498.

No other value seems to glitch like that.

I realize this is some quirky floating point error cluster f-ery, but it caught my attention. I wrote a small rust sandbox to illustrate what’s going on. To test it out for yourself check this repository.

Basically it boils down to the following:

let i = 4.99; // any other .99 number works fine

println!("i = {}", i); // 4.99
println!("i.2 = {:.2}", i); // 4.99
println!("i.10 = {:.10}", i); // 4.9900000000

println!();

let f = format!("{:.10}", i).parse::<f32>().unwrap(); // lets introduce some errors
println!("f = {}", f); // 4.99
println!("f.2 = {:.2}", f); // 4.99
println!("f.10 = {:.10}", f); // 4.9899997711 <- here it goes

println!();

let d = f * 100f32;
println!("d = {}", d); // 498.99997 <- blyatiful
println!("d.2 = {:.2}", d); // 499.00
println!("d.10 = {:.10}", d); // 498.9999694824

println!();

let a = d as i64; // how the code used to work
println!("a = {}", a); // 498

let b = format!("{:.2}", d).parse::<f32>().unwrap() as i64; // workaround
println!("b = {}", b); // 499

In the above code, a represents the value reported by the client and b represents my workaround to fix it.

Replacing i with any .99 number doesn’t behave the same way. To prove that 4.99 is an outlier I made another small binary (in the same repository). Run cargo run --bin itr to test it out for yourself. It starts from 0 until it explodes or you stop it (CTRL+C) and displays "defective" numbers that fail the a == b test.

The only number I get this result for is 4.99.

Any suggestions how to work more properly with floats in Rust?

>Solution :

Casting after performing floating point math, without first rounding, is wrong. When you do:

let unit_amount = (product_price_row.price * 100f32) as i64;

with product_price_row.price as 4.99f32, 4.99f32 * 100f32 in f32 math is clearly producing a value more like 498.99999999998 or the like (I can’t be bothered to find the exact value), due to floating point precision limitations, and casting just drops the data after the decimal, rather than rounding, leaving you with 498 rather than the expected 499.

Instead do:

// Casting to f64 before doing math removes risk of data loss for higher prices
let unit_amount = (product_price_row.price as f64 * 100f64).round() as i64;

or (in cases where the decimal points might go beyond the second decimal place), using libmath:

let unit_amount = math::round::half_to_even(product_price_row.price as f64 * 100f64) as i64;

which uses "Banker’s rounding" to ensure round-offs of half values don’t consistently go one way or the other, minimizing the risk of rounding bias in aggregate.

Either way, instead of that .999999998 getting dropped, you’ll round off correctly, then cast the now safe value.

Add a comment

Leave a Reply

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use

Discover more from Dev solutions

Subscribe now to keep reading and get access to the full archive.

Continue reading