Money
A field type for anything you will add up. It stores an integer count of a currency’s smallest unit alongside the ISO 4217 code, in two database columns, so the amount stays exact and the currency travels with it.
Why not float
Binary floating point cannot represent 0.1. It stores the nearest value it can, which is close enough to print correctly and not close enough to add up. One line item is fine. A subtotal, a tax rate, a percentage discount and a split refund is where it stops being fine.
// float64: the total is not the totalprice := 0.1total := price + 0.2fmt.Println(total) // 0.30000000000000004fmt.Println(total == 0.3) // false// money.Money: an integer count of centsa := money.New(10, "USD") // $0.10b := money.New(20, "USD") // $0.20sum, _ := a.Add(b) // $0.30, exactlyfmt.Println(sum) // 0.30 USD
The drift is small and it accumulates in one direction per operation. It surfaces as a reconciliation that is out by a few cents across ten thousand orders, not as a failing test, which is why it tends to be found by finance rather than by engineering.
Using it
Declare the field as money. Everything downstream follows from that one word.
$grit generate resource Product --fields "name:string,price:money,cost:money,stock:int"
The generated model embeds the type, and GORM expands it into two columns:
type Product struct {ID string `gorm:"type:varchar(36);primaryKey" json:"id"`Name string `gorm:"size:255;not null" json:"name"`Price money.Money `gorm:"embedded;embeddedPrefix:price_" json:"price"`Cost money.Money `gorm:"embedded;embeddedPrefix:cost_" json:"cost"`Stock int `json:"stock"`}
-- what the migration createsprice_amount BIGINT NOT NULL DEFAULT 0price_currency VARCHAR(3) NOT NULL DEFAULT 'USD'cost_amount BIGINT NOT NULL DEFAULT 0cost_currency VARCHAR(3) NOT NULL DEFAULT 'USD'-- which means this works, and would not against a text columnSELECT price_currency, SUM(price_amount)FROM productsGROUP BY price_currency;
"19.99 USD" is not something you can SUM, index or compare. Splitting the amount from the currency is what keeps the aggregate queries the admin dashboard runs on the database side instead of in Go.Over the wire
The API always sends and expects an object. A bare number is accepted on the way in for older clients, and never sent on the way out.
{"name": "Keyboard","price": { "amount": 1999, "currency": "USD" },"stock": 4}
"price": 2500 means $2,500.00, not $25.00. There is no way to tell those two intents apart from the wire, and the hand-written caller writing a price by hand writes 19.99, so that is the reading. If your client already speaks in cents, as Stripe’s API does, send the object form: the ambiguity then does not exist.On the frontend the shared package exports the matching type and the helpers that go with it, so no component has to know what a currency’s exponent is:
import { formatMoney, fromMajor, toMajor, type Money } from "@repo/shared/types";const price: Money = { amount: 1999, currency: "USD" };formatMoney(price) // "$19.99" -- via Intl, in the viewer's localetoMajor(price) // 19.99 -- display only, never arithmeticfromMajor(19.99, "USD") // { amount: 1999, currency: "USD" }formatMoney({ amount: 50000, currency: "UGX" }) // "UGX 50,000", not 500
Currencies without two decimals
Most of ISO 4217 has two decimal places. Enough of it does not that a hardcoded amount / 100 anywhere in your stack is a bug waiting for its first international customer. Both the Go package and the shared TypeScript helpers carry the same table.
| Decimals | Example | Codes |
|---|---|---|
| 0 | money.New(50000, "UGX") is USh 50,000 | BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF |
| 2 | money.New(1999, "USD") is $19.99 | everything not listed in the other two rows |
| 3 | money.New(1500, "KWD") is 1.500 KWD | BHD, IQD, JOD, KWD, LYD, OMR, TND |
Arithmetic
Every operation that could mix currencies returns an error instead. A USD total silently absorbing a UGX line is the failure this type is built to prevent, and it is not one you can catch by reading the number afterwards.
unit := money.New(1999, "USD")line := unit.MulInt(3) // $59.97 -- exact, quantity is a whole numbertax, _ := line.MulFloat(0.2) // rounds half away from zero, once, heretotal, err := line.Add(shipping) // ErrCurrencyMismatch if shipping is not USD// Splitting without losing a cent: 3.34, 3.33, 3.33 -- not three lots of 3.33.// The remainder goes to the earliest parts, which is what accounting expects.parts := money.New(1000, "USD").Allocate(3)
Major() hands back a float so you can print it. Feeding that float back into a calculation puts you exactly where you started; do the arithmetic on the Money value and convert once, at the end.In the admin
A money field generates an amount input with a currency picker beside it, and a right-aligned table column formatted in the row’s own currency, so a UGX row and a USD row in the same table are each correct. The form takes major units, because that is what people type; the conversion happens once, at the edge.
columns: [{ key: "name", label: "Name", sortable: true },// Sorts on price_amount. Exact, because the amount is an integer.{ key: "price", label: "Price", sortable: true, format: "money" },],fields: [{ key: "name", label: "Name", type: "text", required: true },{key: "price",label: "Price",type: "money",// Optional. A shop that trades in one currency should name it: the picker// then has a single option and nobody can pick the wrong one.currencies: ["UGX", "USD"],defaultCurrency: "UGX",},],
Filtering and sorting use the real column names, because that is what reaches the database: ?sort_by=price_amount, ?price_currency=UGX.
ORDER BY price_amount is exact within one currency and meaningless across several: 50,000 UGX sorts above $249.99 because 50000 is the larger integer, not because it is more money. Ordering by real value needs exchange rates, which is an application decision rather than a column. If a table mixes currencies, filter to one before sorting by price.Moving an existing float column
Changing price:float to price:money replaces one column with two, so the data has to be carried across. On an empty table this is nothing; on a live one, write the migration before switching the field.
ALTER TABLE products ADD COLUMN price_amount BIGINT NOT NULL DEFAULT 0;ALTER TABLE products ADD COLUMN price_currency VARCHAR(3) NOT NULL DEFAULT 'USD';-- ROUND, not a cast: the stored float is already 19.989999999999998, and-- truncating it loses the cent you are migrating in order to protect.UPDATE products SET price_amount = ROUND(price * 100), price_currency = 'USD';-- Check before you drop anything.SELECT COUNT(*) FROM products WHERE ABS(price * 100 - price_amount) > 0.5;ALTER TABLE products DROP COLUMN price;
* 100 is correct for a two-decimal currency and wrong for the rest. If the table holds UGX, the amount is already in minor units and the multiplication should not happen at all.