I had Money issues …………On Android.
Then I took care of the pennies and guess what happened ?
Use Case
I was recently working on an App where at the start of the user journey the user loads cash into their app wallet. The user can add one product at a time to the cart . Every time the user adds a product the wallet balance is deducted with the product price. The user can add another product to the cart only if the wallet balance is more or equal to the product value that the user now intends to add next. If the amount is insufficient the user is redirected to add more cash to the wallet.
For example the user loaded in $1 into their App wallet. She is off shopping for candies. She has added following items to her cart with after discount prices on items as follows :
1. "Chocolate Candy" ¢10
2. "Orange Candy" ¢20
3. "Mint Candy" ¢30
Can she add a Mango candy worth ¢40 ? Or will she have to load some more cents in to the wallet ?
Imagine this is our class definition.
public class Product {
private String name;
private double price;
public Product(String item, double price) {
this.name = item;
this.price = price;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
}
With the above class definition, the total wallet balance remaining after subtracting ¢10,¢20,¢30 from $1 will be 0.39999999999999997 and unfortunately the user wont now be able to add another product with ¢40 to the cart with the remaining balance.
Is that correct though ? If we calculate this in our mind we know that we have spent ¢10 + ¢20 + ¢30 = ¢60. So now we should have ¢40 in our wallet!
So why this discrepancy?
double is internally stored as a fraction in binary — like 1/4 + 1/8 + 1/16 + …
double is a base-2 floating-point number. That means it can only represent fractions whose denominators are powers of 2. Most decimal numbers do not fall under this, so, for example, a simple decimal like 0.3 cannot be represented exactly as a double. This inexactness accumulates as we do math operations. For general math with real numbers though, a base-2 floating-point type like double is probably good enough.
Clearly then double is not an ideal way to represent money then when one is supposed to do a lot of price calculations in the app, i mean one candy less is too much! :P
Loss of precision at every step will add up to a great deal of errors in the end
How to solve this discrepancy ?
- BigDecimal : If you want to get exact results from math of decimal numbers, you should use BigDecimal. BigDecimal is a specifically decimal type. That means it’s designed to represent fractions with finite-length decimal (base-10) representations.
private BigDecimal price;
Pro Tip : BigDecimal(double val) constructor must not be passed a floating- point literal as an argument when doing so results in an unacceptable loss of precision. So don’t initialize BigDecimal like new B̵i̵g̵D̵e̵c̵i̵m̵a̵l̵(̵0̵.̵3̵)̵ , initialize it like new BigDecimal(“0.3”).
2. Joda Money : Joda-Money provides a library of classes to store amounts of money. The JDK provides a standard currency class, but not a standard representation of money. Joda-Money fills this gap, providing the value types to represent money.
private Money price;
3. Representing in lowest denomination ($1 = ¢100 , 1Rs = 100 Paise etc): An integral type representing the smallest value possible. In other words your program should think in cents not in dollars/euros. This requires back and forth handling on our part but this seems to be more efficient and clean representation instead of introducing a new datatype like big decimal. It also helps us in making it easy to store in Sqlite database. Just like storing time in mili-seconds and then converting to proper String representation on the fly is more efficient so is money representation.
//$1 = ¢100
private long price;
One can decide upon those solutions that suit one’s use case.
(Again, personally, I have found representing money in lowest denomination to be no frills, performant and easier to represent in local SQLite database.)
The sample test case below checks if a 4th item worth 40 cents can be added to cart with $1 limit and when items worth 10 cent, 20 cent and 30 cent have already been added. The test case where item is represented with double will fail. All the other options viz. BigDecimal, JodaMoney as well as representing money in lowest denominator (e.g $1 = 100 cents) give the correct result.
package src;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import src.model.ItemBigDecimal;
import src.model.ItemDouble;
import src.model.ItemJodaMoney;
import src.model.ItemLowestDenomination;
public class MoneySampleTest {
private List<ItemDouble> productsDouble = new ArrayList<>();
private List<ItemBigDecimal> productsBigDecimal = new ArrayList<>();
private List<ItemLowestDenomination> productsLowestDenomination = new ArrayList<>();
private List<ItemJodaMoney> productsJodaMoney = new ArrayList<>();
private final double RECHARGED_WITH = 1.0;
private final double CANDY_CHOCOLATE = 0.10;
private final double CANDY_ORANGE = 0.20;
private final double CANDY_MINT = 0.30;
private final double CANDY_MANGO = 0.40;
@Before
public void setupBefore() {
productsDouble = new ArrayList<>();
productsDouble.add(new ItemDouble("Candy_Chocolate", CANDY_CHOCOLATE));
productsDouble.add(new ItemDouble("Candy_Orange", CANDY_ORANGE));
productsDouble.add(new ItemDouble("Candy_Mint", CANDY_MINT));
productsBigDecimal.add(new ItemBigDecimal("Candy_Chocolate", new BigDecimal(String.valueOf(CANDY_CHOCOLATE))));
productsBigDecimal.add(new ItemBigDecimal("Candy_Orange", new BigDecimal(String.valueOf(CANDY_ORANGE))));
productsBigDecimal.add(new ItemBigDecimal("Candy_Mint", new BigDecimal(String.valueOf(CANDY_MINT))));
productsLowestDenomination.add(new ItemLowestDenomination("Candy_Chocolate", (long) (CANDY_CHOCOLATE * 100)));
productsLowestDenomination.add(new ItemLowestDenomination("Candy_Orange", (long) (CANDY_ORANGE * 100)));
productsLowestDenomination.add(new ItemLowestDenomination("Candy_Mint", (long) (CANDY_MINT * 100)));
productsJodaMoney.add(new ItemJodaMoney("Candy_Chocolate", Money.of(CurrencyUnit.USD, CANDY_CHOCOLATE)));
productsJodaMoney.add(new ItemJodaMoney("Candy_Orange", Money.of(CurrencyUnit.USD, CANDY_ORANGE)));
productsJodaMoney.add(new ItemJodaMoney("Candy_Mint", Money.of(CurrencyUnit.USD, CANDY_MINT)));
}
@Test
public void testIfWeCanAddCandyWhenUsingDouble() {
double balanceAmount = RECHARGED_WITH;
for (ItemDouble itemDouble : productsDouble) {
balanceAmount -= itemDouble.getPrice();
}
System.out.println("Balance amount in double before buying candy :" + balanceAmount);
Assert.assertTrue(balanceAmount - CANDY_MANGO >= 0);
}
@Test
public void testIfWeCanAddCandyWhenUsingBigDecimal() {
BigDecimal balanceAmount = new BigDecimal(Double.toString(RECHARGED_WITH));
for (ItemBigDecimal itemBigDecimal : productsBigDecimal) {
balanceAmount = balanceAmount.subtract(itemBigDecimal.getPrice());
}
System.out.println("Balance amount in double before buying candy :" + balanceAmount);
Assert.assertTrue(balanceAmount.subtract(new BigDecimal(Double.toString(CANDY_MANGO))).doubleValue() >= 0);
}
@Test
public void testIfWeCanAddCandyWhenUsingLowestDenomination() {
long balanceAmount = (long) (RECHARGED_WITH * 100);
for (ItemLowestDenomination itemLowestDenomination : productsLowestDenomination) {
balanceAmount = balanceAmount - itemLowestDenomination.getPrice();
}
System.out.println("Balance amount in lowest denomination before buying candy :" + balanceAmount);
Assert.assertTrue(balanceAmount - (CANDY_MANGO * 100) >= 0);
}
@Test
public void testIfWeCanAddCandyWhenUsingJodaMoney() {
Money balanceAmount = Money.of(CurrencyUnit.USD, RECHARGED_WITH);
for (ItemJodaMoney itemJodaMoney : productsJodaMoney) {
balanceAmount = balanceAmount.minus(itemJodaMoney.getPrice());
}
System.out.println("Balance amount in Joda Money before buying candy :" + balanceAmount);
Assert.assertTrue(balanceAmount.minus(Money.of(CurrencyUnit.USD, CANDY_MANGO)).isPositiveOrZero());
}
}
We took care of the pennies and the pounds took care of themselves, and money problems in our Android app are gone now, what about you ?
Reference Links :
JODA : https://www.joda.org/joda-money/
BigDecimal : https://blogs.oracle.com/corejavatechtips/the-need-for-bigdecimal
Try playing with decimal to binary conversion and binary arithmetic @ https://www.rapidtables.com/convert/number/index.html.
Test Case on Github : https://github.com/ChaitanyaDuse/MoneyIssues