Money Is an Integer
Payment providers take amounts as whole numbers of the smallest unit, and the conversion from a decimal is one line that is wrong in a specific, reproducible way if you write it the obvious way round.
Here is a line of code that looks correct, passes review, works for almost every value you test, and undercharges customers. Take an amount with two decimal places, multiply by a hundred, convert to a whole number. It is the standard conversion into the units a payment provider wants, and written in that order it produces the wrong answer often enough to matter and rarely enough to ship.
Why the obvious version is wrong
Decimal fractions are not exactly representable in the binary format computers use for non-integer arithmetic. A value like a fifth, or twenty-nine hundredths, is stored as the nearest available number, which is very slightly off.
Multiply that by a hundred and the error comes with it. The result is not a whole number — it is a hair under one. And converting to a whole number by truncation throws away everything after the point, including the part that was about to round up.
So an amount ending in certain cents becomes one cent less. Not randomly: the same amounts are wrong every time, which is why testing a handful of round figures finds nothing.
It is not a rare bug. It is a bug on a fixed set of values, and whether you notice depends entirely on which values you tried.
The fix is to round to the nearest whole number rather than truncating, before the conversion. One function call, in front of the cast. That is the entire difference, and the version without it is what everybody writes first.
And the conversion back
The same boundary exists in the other direction. An account balance comes back in the smallest unit, and turning it into an amount a person reads means dividing.
That direction is safe, because dividing a whole number produces a value whose error is far below anything that would round differently at two decimal places. But it is worth writing with the direction of the conversion noted, because a piece of code handling money in two units is a piece of code where somebody will eventually convert twice or not at all.
The stronger version of that discipline appears on the transfer path: the parameter is named for its unit. A function taking an amount in the smallest unit says so in its own signature, so a caller passing a decimal is visibly wrong at the call site rather than a hundredfold error at the provider.
That is a better defence than any comment. A comment is read by whoever is already looking; a parameter name is read by whoever is writing the call.
A hundred is not the universal factor
Worth stating plainly, because it is the assumption underneath every conversion of this kind.
Two decimal places is a convention, not a rule. Plenty of currencies have none at all — an amount is a whole number and there is nothing smaller. A few have three. A conversion that multiplies by a hundred is correct for the currency it was written for and silently wrong by a factor of ten or a hundred for others.
This connector deals in one currency, and the conversion is written for that currency rather than pretending to be general. That is the honest arrangement: a specific conversion for a specific currency, rather than a general-looking one that is wrong for a quarter of the world.
Elsewhere in the product, where amounts genuinely are multi-currency, the number of decimal places is looked up per currency rather than assumed — because the assumption is the bug, and it is one that reads as correct to anyone who has only ever thought about currencies with two.
Two paths, two shapes of money
A payment provider that both collects and disburses has money moving in two directions, and they are structurally different.
-
Collection starts with an amount
A sale has a value, converted once, sent as part of initialising a payment. The customer completes it wherever they are.
-
Disbursement starts with a recipient
Before money can be sent, the destination has to exist as a record on the provider — an account resolved and confirmed, returning an identifier that later transfers point at.
-
And it comes from a balance
A transfer draws on what your provider account holds rather than from a card. So the balance is a real constraint, readable before attempting anything.
-
The destination is not always a bank
A mobile money wallet and a bank account are different kinds of recipient with different fields, and the recipient is created accordingly rather than assumed to be one or the other.
The second step is the one that surprises people building a payout flow for the first time. You cannot simply send money to an account number. The account is resolved, confirmed against the name it belongs to, and registered — and only then can it receive anything.
That is a fraud control rather than an inconvenience, and it produces a genuinely useful side effect: the account name comes back from the resolution, so a payout can be checked against who it is actually going to before it is sent.
The absent field, again
One small thing on the refund path, because it is the same rule this series keeps meeting.
A partial refund carries an amount. A full refund carries no amount field at all — omitted, rather than sent as the full value or as zero.
That is what the provider expects, and it is the difference between refund all of it and refund this specific figure that happens to equal all of it. The second is a rounding argument waiting to happen; the first cannot disagree with anything.
What is in place, layer by layer
How amounts are handled
Rounded before conversion, never truncated
Amounts are rounded to the nearest whole subunit before the cast, because truncating a binary float loses a cent on a fixed and reproducible set of values.
Parameters named for their unit
A function taking an amount in the smallest unit says so in its signature, so a caller passing a decimal is wrong at the call site rather than a hundredfold error at the provider.
A conversion written for its currency
The subunit factor is specific rather than general, because two decimal places is a convention and a quarter of the world does not follow it.
Balance converted back explicitly
Amounts read from the provider are converted into readable units at a single point, with the direction of the conversion recorded beside it.
Recipients resolved before they are paid
An account is confirmed and registered before it can receive anything, and the resolution returns the name so a payout can be checked against who it is going to.
Bank and wallet recipients handled separately
A mobile money destination and a bank account are different kinds of record, created as what they are rather than forced into one shape.
Transfers drawn from your own balance
Payouts come from what your provider account holds, and the balance is readable before a transfer is attempted.
Full refunds carry no amount
The field is omitted rather than set to the total, so a full refund cannot disagree with a figure over a rounding difference.
Your own provider account
Your keys, your balance, your settlement. Money moves between your customer and your account rather than through ours.
This connector deals in one currency and its conversion is written for that currency. Where the product handles amounts across currencies, the number of decimal places is looked up rather than assumed.
Three positions held on purpose
- Rounding happens before the conversion to a whole number, everywhere money crosses that boundary. Truncation is the default behaviour of the obvious code and it is wrong on a fixed set of values, which is exactly the set a handful of test figures will miss.
- A currency's subunit factor is specific rather than assumed. Multiplying by a hundred is correct for currencies with two decimal places and silently out by a factor of ten or a hundred for the many that do not have two.
- A full refund omits the amount rather than stating it. Sending a figure that should equal the total invites a disagreement over a rounding difference; sending no figure cannot.
Five questions about handling money in an integration
How are amounts converted for the provider?
A good answer sounds like
Rounded, then cast.
What ours actually is
Rounded to the nearest subunit before conversion, because truncation loses a cent on specific values every time.
How does a caller know which unit to pass?
A good answer sounds like
The signature says.
What ours actually is
Parameters are named for their unit, so a decimal passed where a subunit belongs is visibly wrong where it is written.
Does the multiplier work for every currency?
A good answer sounds like
No — say so.
What ours actually is
It is written for the currency this connector uses. Elsewhere the decimal places are looked up per currency.
Can we pay an account number directly?
A good answer sounds like
Not immediately.
What ours actually is
It is resolved and registered first, and the resolution returns the account name for checking.
How is a full refund different from a partial one?
A good answer sounds like
It carries no amount.
What ours actually is
The field is omitted, so a full refund cannot disagree with a stated figure.
Our take
Money in software is a solved problem that keeps being unsolved, because the wrong version works. Hold amounts as whole numbers of the smallest unit, convert at the edges, round rather than truncate at every crossing, and never assume how many decimal places a currency has. All four are known, none is difficult, and every one of them is routinely skipped because a decimal number is the obvious way to hold a price and the errors are a cent at a time. A cent at a time is exactly the size of error that reaches production, survives testing, and is eventually found by an accountant rather than by a monitor.
Take card and bank payments into your own account
Your keys, your balance, your settlement — with amounts converted once, at the edge, in the direction that does not lose a cent.
Talk through payment collectionFrequently asked questions
Why do payment providers use whole numbers rather than decimals?
Because decimal fractions cannot be represented exactly in binary arithmetic, so amounts held that way accumulate tiny errors. Whole numbers of the smallest unit are exact, and the only risk is at the conversion points — which is why rounding rather than truncating there matters.
Could an amount ever be charged incorrectly?
The conversion rounds to the nearest subunit before converting to a whole number, which is the step that prevents the classic one-cent undercharge. The failure it guards against is not random — it happens on the same amounts every time, which is why a few round test figures would never find it.
Can we pay a supplier straight from a bank account number?
Not in one step. The account is resolved and registered with the provider first, which returns the name it belongs to. That is a fraud control, and it has the useful side effect that a payout can be checked against who it is actually going to before it is sent.
Can we send money to a mobile wallet as well as a bank account?
Yes, and they are created as different kinds of recipient rather than being forced into one shape, because the fields a wallet needs are not the fields a bank account needs.
Where does the money for a transfer come from?
Your balance with the provider, so what your account holds is a real constraint on what can be sent. The balance is readable before a transfer is attempted rather than discovered by a refusal.