How I Keep One Money Convention Across All My Systems
On this page
- Introduction
- A. One amount, many layers
- B. Why it has to be one convention
- C. The approaches I rejected
- D. Thirteen obstacles in the way
- Method
- A. Integers, because they survive every layer
- B. A suffix that states the scale
- C. The scale comes from ISO 8583, and it is not rounding
- D. Percentages in basis points, rounded in one place
- E. A type that cannot be mistaken for a number
- F. The browser converts, the server never guesses
- G. Imported files are normalised on the server
- H. Exported files go back to readable numbers
- I. The form field speaks rupiah, the value stays an integer
- J. The range is known and written down
- K. Printed documents are formatted from the integer
- L. Older data is converted once, at import
- M. Numbers that are not money keep their own types
- Results and Discussion
- A. What a new module inherits
- B. What it costs
- C. Where this convention fits
- Conclusion
- Terms
Introduction
A. One amount, many layers
Almost every system I build handles money somewhere. An order system adds up item prices, shipping fees and discounts. A sales-reporting service sums what a marketplace paid out against what buyers were charged. A personnel system holds salary tables and prints decrees that state an employee's new basic salary in figures and in words. These systems are written in different languages — Rust for some services, PHP for others, TypeScript in the browser — and each amount passes through several layers on its way: a database column, a domain calculation, a JSON field in an API response, a form field a person types into, an exported spreadsheet, a printed document.
Every one of those layers has its own idea of what a number is. A database can store a fixed-point decimal; Rust has integers of several sizes and floating-point numbers; PHP silently turns a large integer into a float; JSON has a single number type that most clients read as a 64-bit float; JavaScript has only that float unless told otherwise. An amount that is exact in one layer can quietly stop being exact in the next, and the error does not announce itself. It appears weeks later as a total that is off by one cent, or as a report whose rows do not add up to its own footer.
The challenge is to keep one money convention across all my systems: every amount stored, calculated, transmitted and displayed exactly, in the same form in every language and every layer, so that any module I build can rely on it without re-deciding it.
Both halves of that sentence matter. "Exactly" means that no layer is allowed to lose or invent a fraction of a rupiah, ever. "The same form everywhere" means that a developer — usually me, months later — who reads a field in a database, an API response or a test should know at once what the number means and how to treat it, without opening documentation for that particular module. A convention that only one module follows is not a convention; it is a local habit that the next module may contradict.
B. Why it has to be one convention
Money crosses module boundaries more than almost any other value. An order created in one service is reported by another, invoiced by a third, and shown in a browser by a fourth. If each of them chose its own representation — one storing rupiah with decimals, another storing whole rupiah, a third storing cents — then every boundary would need a conversion, and every conversion is a place where a factor of one hundred can be applied twice or not at all. The resulting defect is easy to picture: an amount one hundred times too large in one screen and correct in another, because two modules disagree about the scale and nothing in the data says which one is right.
So I made the decision once, wrote it down as a rule that applies to every system I build, and treat it as part of how I start a module rather than as a choice each module makes. The rule is borrowed from the payment industry, which faced the same problem decades ago and settled it in a standard.
C. The approaches I rejected
The most common approach is floating-point numbers: a float or double column, a float in the code, a float in JSON. I rejected it because a binary floating-point number cannot represent most decimal fractions exactly. The classic demonstration is that 0.1 + 0.2 does not equal 0.3 in floating point; it equals 0.30000000000000004. For one amount the difference is invisible. Summed across thousands of order lines, compared for equality, or multiplied by a percentage, it becomes a wrong cent.
The second approach is a fixed-point decimal type: DECIMAL(15,2) in the database and a decimal library in the code. It is exact inside the database, and I still use decimals for quantities that are not money. I rejected it as the money convention because it does not survive the trip. A database driver often hands a decimal to the code as a string; JSON has no decimal type, so the value leaves the API either as a string that every client must parse or as a number that the browser reads as a float. Exactness that ends at the API boundary is not the exactness I need.
The approach I chose is the one the card-payment world uses: an amount is an integer count of the currency's smallest unit. An integer is exact in every database, every language and every JSON parser I use, so it is the only representation that stays exact through every layer.
D. Thirteen obstacles in the way
Easy to state, awkward to build. Thirteen things stood between "store money as integers" and a convention that every system follows without surprises.
The first is that floating-point numbers cannot hold decimal amounts exactly. Floats are the rejected alternative, and so is a decimal column, because it becomes a float or a string the moment it crosses JSON.
The second is that the scale of an integer is invisible. The number 150000 could be rupiah or hundredths of a rupiah, and relying on documentation to tell them apart is the rejected alternative.
The third is choosing a scale that is a standard rather than my own invention, and making clear that it is not rounding. An ad-hoc scale per module is the rejected alternative.
The fourth is that percentages produce fractions. A discount of 2.5 per cent on an odd amount does not land on a whole unit, and converting to a float for the multiplication is the rejected alternative.
The fifth is that an amount can be mistaken for an ordinary number between layers. Passing money around as a bare integer is the rejected alternative, because a quantity or an identifier has the same type.
The sixth is deciding which side converts. Letting both the browser and the server multiply "to be safe" is the rejected alternative; it is exactly how amounts end up one hundred times too large.
The seventh is that files uploaded by people use local number formats. A spreadsheet may say 2.000,35 or 2000.35 or Rp 1.250,50, and parsing it as a float is the rejected alternative.
The eighth is that exported files must be readable by people. Exporting raw integers is the rejected alternative, because nobody reads 37500050 as three hundred and seventy-five thousand rupiah and fifty.
The ninth is that people type amounts the way they write them. A form field that asks for hundredths of a rupiah is the rejected alternative.
The tenth is the range of the integers. Assuming that every layer can hold any integer is the rejected alternative, because the browser cannot.
The eleventh is that printed documents state amounts in figures and in words. Formatting them through a float is the rejected alternative.
The twelfth is data from older systems, stored in whole rupiah. Converting it lazily, wherever it happens to be read, is the rejected alternative.
The thirteenth is that not every number is money. Applying the rule to every decimal value is the rejected alternative, because it would make the rule meaningless.
The order follows the life of a value. The first three define what an amount is; the fourth and fifth govern how it is calculated and passed around; the sixth to the ninth deal with the edges where amounts enter and leave a system; the last four are the limits and exceptions that keep the rule honest. Getting the definition wrong would make every later answer wrong, which is why it comes first.
Method
A. Integers, because they survive every layer
This is the answer to the first obstacle: every amount, in every system I build, is a whole number. The database column is a 64-bit integer, the domain value is a 64-bit integer, and the JSON field is an integer. Addition, subtraction and comparison of integers are exact, so a sum of ten thousand order lines is the same sum in the database, in the service and in the browser, and two amounts that should be equal are equal.
(
id BIGSERIAL PRIMARY KEY,
quantity INTEGER NOT NULL,
unit_price_iso8583 BIGINT NOT NULL, -- amount × 100
line_total_iso8583 BIGINT NOT NULL -- quantity × unit price, exact
);I keep decimal types for what they are good at — inside the database, for values that are not money — but money itself never becomes a decimal or a float, at any layer, at any time.
B. A suffix that states the scale
An integer alone does not say what it counts, so I made the scale part of the name. Every database column and every API field that holds money ends in _iso8583: price_iso8583, shipping_fee_iso8583, total_iso8583. Anyone who reads a row, a log line or a JSON response knows that the number is in hundredths of a rupiah without looking anything up, and a field without the suffix is, by the same rule, not money.
The suffix is deliberately unusual. A generic name such as amount_cents would read naturally, but it would also be easy to forget, and different modules would drift towards different spellings. A single, slightly odd suffix is easy to search for across every repository I own, and a review only has to ask one question of any money field: does it end in _iso8583?
C. The scale comes from ISO 8583, and it is not rounding
ISO 8583 is the international standard for the messages exchanged when a card payment is authorised. It carries the transaction amount as a whole number in the currency's minor unit, and the number of decimal places of each currency comes from ISO 4217, the standard list of currency codes. The rupiah is listed with two decimal places, so in this convention an amount is multiplied by one hundred when it is stored and divided by one hundred when it is shown: Rp2,785,700.00 is stored as 278570000.
This is not rounding. Rounding throws digits away; this conversion keeps them. The two digits after the decimal point are still there, written into the integer instead of after a separator, and dividing by one hundred brings back exactly the amount that went in. The purpose is the one described above: integer arithmetic is exact, and floating-point arithmetic on fractions is not.
Borrowing the scale from a standard rather than inventing one has a practical benefit. The rule needs no justification in each project, it matches how payment gateways already exchange amounts, and a currency with a different number of decimal places would follow the same standard instead of a new private agreement.
D. Percentages in basis points, rounded in one place
A percentage of an integer is usually not an integer. I express percentages in basis points — one per cent is 100 basis points, so 2.5 per cent is 250 — which keeps the percentage itself an integer. The multiplication happens in a 128-bit integer so that a large amount times a large rate cannot overflow, and the division back to minor units rounds half up.
pub const BASIS_POINT_DIVISOR: i64 = 10_000;
This function is the only place where money is rounded. I chose half-up so that results stay consistent with the two-decimal rounding that applied before amounts became integers; changing the rounding rule now would change historical totals. Because rounding happens in one function, the rule is visible, tested, and impossible to apply twice by accident.
E. A type that cannot be mistaken for a number
In Rust, where the type system can carry meaning, an amount is wrapped in its own type, a newtype around a 64-bit integer. A function that expects money does not accept a quantity or an identifier, and the compiler rejects the mistake before it can run.
/// Money in minor units (amount × 100).
;
In PHP, which has no such types, I rely on the suffix and on casting money columns to integers in the model, so that a value read from the database is never a string or a float by the time the code touches it. The protection is weaker than a compiler, but the naming rule makes the remaining mistakes visible in review.
F. The browser converts, the server never guesses
The conversion between "what a person sees" and "what the system stores" happens in exactly one place: the user interface. It multiplies by one hundred before it sends an amount and divides by one hundred when it shows one. The API accepts and returns integers only; it never receives a fraction to interpret.
Placing the conversion at the edge, and only there, means that every service behind the API works with one representation. No service has to wonder whether an incoming amount has already been converted, and the defect of an amount multiplied twice cannot arise from two well-meaning layers.
G. Imported files are normalised on the server
Files are the exception to "the browser converts". When a person uploads a spreadsheet exported from a marketplace or a bank, they upload it as it is, and the amounts in it are written in whatever local format produced the file. The server normalises them: it removes currency symbols and spaces, treats a comma as the decimal separator when there is one, reads a dot followed by exactly two digits as a decimal point, reads any other dot as a thousands separator, and parses the result with a decimal type rather than a float before multiplying by one hundred.
// "2.000,35" (id) and "2000.35" (en) both become 200035; "123.000" is thousands
let normalized = if value.contains else if matches! else ;
let amount: i64 = .round.try_into?;The rule for the ambiguous case was a decision, not a discovery: in rupiah, three digits after a dot are almost always thousands, and two digits are almost always cents. The normaliser is covered by tests that list each format it accepts, so the decision is written down where it can be checked.
H. Exported files go back to readable numbers
The opposite edge is the export. A spreadsheet meant for people turns each integer back into a decimal number with no trailing zeros: 37500000 becomes 375000, and 37500050 becomes 375000.50. The conversion uses integer division and remainder, so the text in the file is exactly the stored amount and not an approximation of it.
I. The form field speaks rupiah, the value stays an integer
A person types an amount the way they write it: 1.486.500,50, with dots between thousands and a comma before the cents. The money input field in my interfaces formats the text while it is typed and keeps its bound value as an integer count of hundredths. The person never sees the integer, and the code never sees the formatted text.
/** "1.486.500,5" → 148650050; empty → null */
const parse = (value: string): number | null => {
const [whole = '', fraction = ''] = value.replace(/[^\d,]/g, '').split(',')
if (!whole && !fraction) return null
return Number(whole || '0') * 100 + Number((fraction + '00').slice(0, 2))
}Because the field parses digits rather than converting a float, typing fifty cents gives exactly 50, never 49.99999.
J. The range is known and written down
On the server an amount is a signed 64-bit integer, which holds more than ninety quadrillion rupiah in minor units — far beyond any amount these systems will see. The browser is the narrower layer: a JavaScript number represents integers exactly only up to 253 − 1, which in minor units is about ninety trillion rupiah. That is still well beyond any single order, salary or monthly report I handle, so I accepted the limit rather than sending amounts as strings, and wrote it into the convention so that a future module with larger sums would know where the boundary is.
K. Printed documents are formatted from the integer
Official documents state an amount twice: in figures and in words. Both are produced from the integer with integer division, never through a float, so the figures and the words cannot disagree with each other or with the stored value.
L. Older data is converted once, at import
Older systems I migrate from usually store whole rupiah. Their amounts are multiplied by one hundred exactly once, in the import step that copies them into the new schema, and nowhere else. After the import there is only one representation in the new system, and no reading code has to know that some rows came from somewhere else.
M. Numbers that are not money keep their own types
The convention is for money only. Ratios such as a repeat-order rate or an approval rate, and physical quantities such as the weight of a parcel, stay decimal values without the suffix. Keeping them out is what keeps the suffix meaningful: if every decimal number ended in _iso8583, the suffix would stop telling anyone that a value is money.
Results and Discussion
A. What a new module inherits
When I start a new module, the money question is already answered. Columns are 64-bit integers with the suffix, percentages are basis points, rounding happens in one function, the browser converts at the edge, imports and exports have their normalisers, and the interface uses the same money field. The module does not need a design discussion about money, and a reader who knows the convention from one system can read money in any other system I have built.
The convention has also changed how I review code. A money defect usually shows itself as a violation of one of these rules — a float where an integer should be, a field without the suffix, a second place that rounds — so the review becomes a short checklist rather than an investigation.
B. What it costs
The cost is a conversion at every human edge: forms, exports, printed documents. Each of those needs code that a decimal-based design would not need, and that code has to be tested. A developer new to the convention also has to learn that a field ending in _iso8583 holds one hundred times the visible amount. I consider both costs small compared with the alternative, which is a class of defects that is invisible until a total does not add up.
C. Where this convention fits
The convention fits any system that adds, compares and reports amounts in a currency with a fixed number of decimal places. It is less suited to values that genuinely need more precision than the currency's minor unit, such as exchange rates or unit prices quoted to four decimals; those stay decimal values and are converted to minor units only at the point where they become an amount someone pays.
Conclusion
Money in every system I build is an integer count of hundredths of a rupiah, named with the _iso8583 suffix, calculated with integer arithmetic, rounded half up in one function, converted only at the edges where people read and type it, and limited to values that are actually money. The scale comes from ISO 8583 and ISO 4217, so it is not my own invention, and it is not rounding: the digits after the decimal point are kept, written into the integer.
The value of the rule is that it is one rule. Each module that follows it can be read, combined and reviewed with the others without a conversion at every boundary, and the most common money defects — an inexact sum, an amount one hundred times too large — have no place to begin.
Terms
- Minor unit
- The smallest unit of a currency, such as one hundredth of a rupiah; storing amounts as whole numbers of it keeps arithmetic exact.
- ISO 8583
- The international standard for card-payment messages, which carries amounts as whole numbers of the currency's minor unit.
- ISO 4217
- The standard list of currency codes, which also states how many decimal places each currency has; the rupiah has two.
- Basis point
- One hundredth of one per cent; expressing a percentage in basis points keeps it a whole number.
- Newtype
- A distinct type that wraps another, such as an integer, so the compiler refuses to mix it with ordinary values of the wrapped type.
- Round half up
- A rounding rule in which a remainder of exactly one half rounds to the larger value.
Technologies covered
- PHP
- Rust
- TypeScript
- Vue
- ISO 8583