SCUA

How-to

Handle money

Money has one rule that matters more than any other: never store it in a float. 0.1 + 0.2 isn't 0.3, and across a few hundred transactions that error becomes real lost cents. SCUA's answer is a first-class money type: an exact amount that carries its currency with it. You write it as an amount and a currency code, and it does what money should. The arithmetic stays exact, currencies won't silently mix, and the totals add up.

(This is the tool for real-world money. Game counters like gold and gems are just an int; the huge "currencies" in idle games are a different thing again — see numbers. This page is about exact amounts with a currency.)

#Write it as an amount and a currency

A money literal is a number followed by a three-letter ISO 4217 currency code:

let price = 19.99 USD
let fee   = 500 JPY
let vat   = 12.50 EUR
print(price)   -- $19.99
print(fee)     -- ¥500   (yen has no minor unit)

The amount is exact — 19.99 here is read as an exact decimal, not the binary float 19.99 would be on its own. The currency travels with the value, so an amount always knows what it is, and printing picks the right symbol and number of places for that currency.

For a currency you only know at run time, build one with money.of:

import money
let owed = money.of(amount, code)   -- amount is a decimal/int, code a string like "USD"

An unknown currency code, or more decimal places than the currency allows (100.5 JPY — yen has none), is an error you find right away, not a wrong number later.

#Arithmetic that can't quietly go wrong

Money is a quantity of a currency, and the arithmetic follows from that. A plain number is a scalar (a count, or a rate, or a factor), so you multiply and divide money by numbers, and add money only to money of the same currency:

let unit  = 400 USD
let gross = unit * 3         -- $1,200.00   (three at $400 each)
let tax   = gross * 0.08d    -- $96.00      (8% of the total)
let total = gross + 96 USD   -- $1,296.00   (money + money, same currency)

Dividing money by money cancels the currency and gives you a plain ratio — handy for margins and percentages — while dividing money by a number gives you a share:

print(80 USD / 20 USD)   -- 4.000000   (a plain number — how many $20s are in $80)
print(100 USD / 3)       -- $33.33     (one rounded third)

Everything that shouldn't have a meaning is caught, most of it before the program even runs:

price + 5           -- error: cannot add money and a plain number — make it money first, e.g. `5 USD`
100 USD + 100 EUR   -- error: cannot add different currencies (USD and EUR)
price * price       -- error: cannot multiply money by money — scale by a quantity or rate
100 USD < 50 EUR    -- error: cannot order different currencies

Adding a bare number to money, multiplying two amounts, or mixing currencies is never a silent wrong total. Each one is a clear error with the fix written into it. The only check that waits for run time is which currency two amounts actually are; everything else the compiler catches for you.

#Formatting, splitting, rounding, converting

The money module handles the currency-specific jobs. Because a money value already carries its currency, money.format needs nothing else:

import money
print(money.format(1234.5 USD))   -- $1,234.50
print(money.format(1234.5 EUR))   -- €1,234.50
print(money.format(1234 JPY))     -- ¥1,234
print(money.format(-742.5 USD))   -- -$742.50

Splitting a bill evenly is the classic place naive code drops or invents a cent. money.split hands back shares that sum back to exactly the total:

print(money.split(100 USD, 3))   -- [$33.34, $33.33, $33.33]

The leftover cent goes to the first share rather than vanishing. money.round rounds a computed amount to the currency's places (banker's rounding, the standard for money), and money.convert changes currency at a rate you supply. There's no hidden exchange rate anywhere:

print(money.round(100 USD / 3))              -- $33.33
print(money.convert(100 USD, "EUR", 0.92d))  -- €92.00

#Money in a table

Because the currency rides along with each value, a column of money stays money through a whole calculation. Read a CSV, total a column, group by a key, and the answer comes back exact and currency-tagged. A column that accidentally mixes currencies stops with a clear message instead of adding dollars to euros:

let sales = csv("region,amount
west,400.00 USD
east,150.00 USD
west,50.00 USD")

print(sales.total("amount"))                    -- $600.00
print(sales.group_sum("region", "amount").to_table())
┌────────┬─────────┐
│ region │ total   │
├────────┼─────────┤
│ west   │ $450.00 │
│ east   │ $150.00 │
└────────┴─────────┘

That's a spreadsheet's worth of work in three lines, and it's exact, currency-aware, and loud about a mixed-currency mistake. See summarize and query data for more on tables.

#The short version

  • Write money as an amount and a currency: 19.99 USD. It's exact, never a float, and the currency travels with it.
  • Multiply or divide money by a number (a quantity or rate); add money only to money of the same currency. Everything else is an error, most of it at compile time.
  • money.format for display, money.split for penny-exact shares, money.round for a settled amount, money.convert for an explicit-rate conversion.
  • In a frame, a money column stays money — total and group_sum give exact, currency-tagged answers, and a mixed-currency column fails loudly.
  • Build one dynamically with money.of(amount, code).