How to Handle Money in an AI-Built App
An AI-built app that stores prices as floating point will be wrong by cents within a week. The fix is integers, with two traps worth knowing.
Store money as an integer number of minor units. A price of 19.99 euro is the integer 1999, not the float 19.99. Do the arithmetic in integers and convert to a decimal string only when you display it. That one rule prevents most money bugs in small applications, and generated code frequently ignores it.
This is a sibling problem to handling time zones in an AI-built app: both look trivial, both have a correct answer that is well known, and both produce bugs that surface weeks later in front of a customer.
What goes wrong, with real numbers
Floating point cannot represent most decimal fractions exactly. The classic demonstration takes one line in any language:
>>> 0.1 + 0.2
0.30000000000000004
>>> 19.99 * 3
59.969999999999999
>>> round(2.675, 2) # a price of 2.675 rounded to cents
2.67 # not 2.68That last one is the expensive case. The value stored is fractionally below 2.675, so rounding goes down. It is not a bug in the rounding function; it is what happens when you round a number that was never exactly what you thought.
A single cent does not matter until it does. It matters when an invoice total does not equal the sum of its lines, when a refund leaves a balance of negative 0.01, or when a payment provider rejects a charge because your total disagrees with theirs by one unit. All three are ordinary support tickets, and all three are hard to reproduce.
The rule, in a schema
Two columns per amount: the integer and the currency. Never one without the other.
create table invoice_line (
id uuid primary key default gen_random_uuid(),
invoice_id uuid not null references invoice(id),
description text not null,
quantity integer not null check (quantity > 0),
unit_amount bigint not null, -- minor units, e.g. 1999 = 19.99
currency char(3) not null, -- ISO 4217, e.g. 'EUR'
constraint positive_amount check (unit_amount >= 0)
);bigint rather than integer because a 32 bit integer tops out around 21 million in a two decimal currency, and because some currencies have no minor unit at all and produce large numbers naturally. The column costs four extra bytes and removes a whole class of future migration.
If your database has a true decimal type, numeric(19,4) is also correct and is easier to read in a query console. What is never correct is float or double precision. If a generated migration hands you one of those for a money column, change it before any data lands. More on that choice in choosing a database.
The trap: multiplying by 100 is not the conversion
The obvious helper is amount times 100. It is wrong for a meaningful set of currencies, and the failure is silent because the number still looks plausible.
Currency | Minor units | 1 unit in minor units | What a times-100 rule does |
|---|---|---|---|
EUR, USD, GBP | 2 | 100 | Correct |
JPY, KRW | 0 | 1 | Charges 100 times too much |
BHD, KWD, TND | 3 | 1000 | Charges 10 times too little |
A yen amount of 5000 sent as 500000 is a real charge for a real amount of money, and the customer will notice before you do. Payment providers document the exact rule they expect; Stripe publishes the list of zero-decimal and three-decimal currencies it uses. Read your provider's list rather than assuming, and drive the conversion from a table:
EXPONENT = {"JPY": 0, "KRW": 0, "BHD": 3, "KWD": 3, "TND": 3}
def to_minor(amount_decimal, currency):
exp = EXPONENT.get(currency, 2)
return int((amount_decimal * (10 ** exp)).quantize(Decimal("1")))Where to round, and where not to
Rounding is not the enemy. Rounding in the wrong place is. The rule that survives an audit: round once, as late as possible, and never round an intermediate value that another calculation will consume.
Percentages, tax and discounts produce fractional minor units. Keep the fraction through the calculation and round at the end of the line item, not at each step.
Sum the rounded line totals to get the invoice total. Do not round the sum of the unrounded lines, or the total will disagree with the visible lines by a cent and nobody will be able to explain why.
Pick one rounding mode and write it down. Half-up is the common commercial choice; bankers rounding is the common financial one. The problem is mixing them, not choosing wrongly.
Never round for display and then store the displayed value. That is how a rounding error becomes permanent.
Splitting an amount without losing a cent
Dividing 10.00 euro three ways gives 3.33 three times and loses a cent. Allocate the remainder deliberately rather than letting it evaporate:
def split(total_minor, n):
base, rem = divmod(total_minor, n)
return [base + (1 if i < rem else 0) for i in range(n)]
split(1000, 3) # [334, 333, 333] sums back to exactly 1000Whoever gets the extra cent is a business decision. Losing it is not a decision, it is a reconciliation problem that appears at month end.
Displaying an amount
Integers in the database, formatted strings on the screen, and the conversion happens once at the edge. Do not let a formatted string travel back into a calculation.
from babel.numbers import format_currency
def display(minor, currency, locale="en_GB"):
exp = EXPONENT.get(currency, 2)
return format_currency(Decimal(minor) / (10 ** exp), currency, locale=locale)
display(199900, "EUR", "de_DE") # '1.999,00 EUR'
display(5000, "JPY", "ja_JP") # 'JPY5,000'Use a locale-aware formatter rather than string concatenation. Decimal separators, thousands separators and symbol position all vary, and a hand-rolled formatter that prints 1,999.00 to a German customer looks like an amateur mistake because it is one.
One rule that saves confusion later: never show a currency amount without its currency, anywhere, including internal admin screens. An unlabelled 5000 has cost people real money.
If you already shipped floats
Common, and fixable, as long as you do not convert in place. The safe sequence:
Add the new integer columns alongside the existing float ones. Do not drop anything yet.
Backfill by rounding each float to the currency's minor unit, using the same rounding mode you intend to use going forward.
Reconcile before you switch. Sum the old column and the new one and compare against whatever external record you have, usually your payment provider's totals. Investigate any difference rather than assuming it is rounding, because a systematic gap means the backfill rule is wrong.
Write to both columns for a release, read from the integer one, then drop the float column once the totals have agreed for a full billing cycle.
Step three is the one people skip. A backfill that quietly changed a thousand historical invoices by a cent each is a problem you want to find during the migration, not during an audit.
The underlying reason for all of this is that binary floating point cannot represent most decimal fractions, which is set out in exhaustive detail in the classic reference on floating point arithmetic. The short version is the one at the top of this article: use integers.
The next problem after storage
Once amounts are stored correctly, the arithmetic that trips people up moves to tax. Rates vary by the customer's location rather than yours, they change on dates set by other people, and the rounding rules are sometimes specified in law rather than left to you. That is a separate problem with its own traps, covered in sales tax on a subscription.
Three tests worth writing
A line total equals unit amount times quantity, exactly, for a quantity that is not one.
An invoice total equals the sum of its line totals, for an invoice whose lines have a fractional tax rate.
A refund of the full amount leaves a balance of exactly zero, not a value that merely displays as zero.
Those three catch the great majority of real money bugs, and they belong in the set described in testing before launch.
Frequently asked questions
Should I store the currency on every row?
Yes. An amount without a currency is not an amount. It is tempting to keep currency on the account and treat every row as implicitly in that currency, and it works right up until a customer changes currency or you add a second market, at which point historical rows are unreadable.
What about exchange rates?
Store the original amount, the converted amount, the rate and the timestamp of the rate. All four. Recomputing a historical conversion from a current rate produces a number that matches no document anyone was ever sent.
Does using a payment provider make this unnecessary?
It handles the charge, not your records. You still store amounts, still show totals and still issue refunds, and the provider expects integer minor units at the boundary anyway. See adding payments to an AI-built app for the integration side.
Is a decimal type good enough on its own?
A true fixed-precision decimal is correct for storage. You still have to decide where rounding happens and how remainders are allocated, because those are business rules rather than type properties. The type prevents one failure mode; the rules prevent the rest.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


