I recently went through our Perl, Python, PHP, and JavaScript code making sure sales tax and VAT calculations were right, particular when the sale amount and tax rate were both in floating point (damn legacy code...). During the course of this, I found some good test cases. They are given below.

Let R = the tax rate x 10000, in a jurisdiction where the tax rate is an integral multiple of 0.0001. Note that R is an integer.

Let A = the sale amount x 100, in a jurisdiction where prices are an integral multiple of 0.01. I.e., in the US, A is the sale amount in cents. Note that A is an integer.

Let T = the tax * 100, or in US terms, the tax in cents. Note that T is an integer.

If you can arrange to keep your amounts in cents and your rates in the x 10000 form (or whatever is appropriate for the jurisdiction), then you only need integers and things are simple:

def tax(A, R):
T = (A * R + 5000)//10000
return T

You probably have to go from cents to dollars somewhere, such as when informing the user of prices, taxes, and totals. I believe that integer/100, rounded to 2 places in all cases and printed in all of the above languages will be correct, but I kind of cheated and for display results I treated it as a string manipulation problem, not a number problem (which also takes care of making sure amounts less than $1 have a leading 0, and that multiples of 0.1 have a trailing zero) [1].

If you don't have the amount and rate in the nice integer forms above, but rather have them in floating point such as you get from parsing a string like 12.34 (for a price of $12.34) or 0.086 (for a tax of 8.6%), here are three functions to return the tax in cents that might seem reasonable, and you might think are properly handling rounding:

def tax_f1(amt, rate):
tax = round(amt * rate,2)
return round(tax * 100)

def tax_f2(amt, rate):
return round(amt*rate*100)

def tax_f3(amt, rate):
return round(amt*rate*100+.5)

Alas, they are all flawed.

input f1 f2 f3
------------- --- --- ---
1% of $21.50 21 22 22 ( 22 is right)
3% of $21.50 65 64 65 ( 65 is right)
6% of $21.50 129 129 130 (129 is right)
10% of $21.15 211 211 212 (212 is right)

It does work to convert from floating point to the x 100 and x 10000 integer form, and then use the integer function given earlier:

def tax_f4(amt, rate):
amt = round(amt * 100)
rate = round(rate * 10000)
tax = (amt * rate + 5000)//10000
return tax

def tax_f5(amt, rate):
amt = int(amt * 100 + .5)
rate = int(rate * 10000 + .5)
tax = (amt * rate + 5000)//10000
return tax

Both of those are right in the test cases above, and I believe in all other cases (well, all other cases where everything is positive...). For Python I've done brute force testing of all combinations of amount = 0.01 to 25.00 in steps of 0.01 and rate = 0.0001 to 1.0000 in steps of 0.0001 to verify that.

I've also done a brute force C test that involved sscanf(..., "%lf",...) of strings of the form "0.ddd...ddd" where the 'd' are decimal digits, and there are up to 9 of them. In all cases multiplying the resulting double by 10^k, where k is the number of digits after the decimal point and called round() on that gave the correct integer. Assuming that Python, PHP, etc., are using IEEE 754 when they do floating point, the results should be the same in all of those, which is why I believe that tax_f4 and tax_f5 should work for all cases, not just the ones I actually tested in the Python brute force test.

I did another C test, over the same range as the sscanf test, to verify that given an integer I, in the range [0, 10^k] for positive k up to 9, if you computer (double)I/10^k, then multiply that by 10^k and round(), you get back I.

My conclusions (assuming IEEE 754 or something that behaves similarly):

1. It is OK to store money values and tax rates in floating point, at least as long as you have 9 or fewer digits after the decimal point. Just avoid doing calculations in this form.

2. Converting from a floating point representation to an integer x 10^k representation by doing a floating point multiply by 10^k and a round to nearest integer works, at least as long as k <= 9.

3. sscanf '%lf', and I expect most reasonable string to float parsers, if applied to a floating point number of the form 0.ddd... with up to 9 digits after the decimal point, will work as expected, in the sense that they will give you a floating point number that when converted to integer x 10^k representation as described in #2 will give you the integer you expect and want.

4. I did not do any tests of floating point amounts that had large integer parts. With a large enough integer part, the places where I mention k <= 9 above might need to have that 9 lowered.

[1] e.g., in PHP:

function cents_to_dollars($cents)
{
$cents = strval($cents);
while (strlen($cents) < 3)
$cents = '0' . $cents;
return substr($cents,0,strlen($cents)-2) . '.' . substr($cents,-2);
}