Core Concepts · Reference

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 total
price := 0.1
total := price + 0.2
fmt.Println(total) // 0.30000000000000004
fmt.Println(total == 0.3) // false
// money.Money: an integer count of cents
a := money.New(10, "USD") // $0.10
b := money.New(20, "USD") // $0.20
sum, _ := a.Add(b) // $0.30, exactly
fmt.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.

Terminal
$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:

apps/api/internal/models/product.go
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 creates
price_amount BIGINT NOT NULL DEFAULT 0
price_currency VARCHAR(3) NOT NULL DEFAULT 'USD'
cost_amount BIGINT NOT NULL DEFAULT 0
cost_currency VARCHAR(3) NOT NULL DEFAULT 'USD'
-- which means this works, and would not against a text column
SELECT price_currency, SUM(price_amount)
FROM products
GROUP BY price_currency;
Two columns, not oneA single column holding "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
}
A bare number means major unitsPosting "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 locale
toMajor(price) // 19.99 -- display only, never arithmetic
fromMajor(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.

DecimalsExampleCodes
0money.New(50000, "UGX") is USh 50,000BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF
2money.New(1999, "USD") is $19.99everything not listed in the other two rows
3money.New(1500, "KWD") is 1.500 KWDBHD, 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 number
tax, _ := line.MulFloat(0.2) // rounds half away from zero, once, here
total, 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() is for displayMajor() 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.

apps/admin/resources/products/products.ts
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.

Sorting compares minor units, not valueORDER 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;
Multiply by the right power of ten* 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.