This spec defines how monetary value is stored, computed, and reconciled. It exists because a floating-point cent and a nullable price are silent liabilities: they don't error, they drift. The model stores money as exact integers, records movement as balanced double-entry lines, and pushes the balancing invariant down to a database constraint.
Definition
Representation
Every monetary amount is a signed integer count of the currency's minor unit (cents) plus an explicit currency code. There is no floating-point money anywhere in the system — not in a column, not in an intermediate calculation, not in an API payload. Display formatting is a presentation concern applied once, at the edge.
| Column | Type | Constraint | Notes |
|---|---|---|---|
| amount_minor | BIGINT | NOT NULL | Signed integer count of minor units (cents) |
| currency | CHAR(3) | NOT NULL, ISO 4217 | No mixed-currency arithmetic without conversion |
| — (never) | FLOAT/DOUBLE | prohibited | Binary floats cannot represent 0.10 exactly |
Entity model
Entity-relationship model
A double-entry ledger: an account holds a balance, a transaction is an atomic financial event, and every transaction is composed of two or more lines that reference accounts. The sum of a transaction's line amounts is always zero — value moves between accounts, it is never created or destroyed on a line.
Definition
The balancing constraint
The core invariant — every transaction's lines sum to zero — is enforced by the database, not by application discipline. A transaction that does not balance cannot commit. This turns a whole class of reconciliation bugs into an immediate, un-ignorable write failure.
-- Amounts are exact integers, and non-negative where the domain requires it.
ALTER TABLE products
ADD CONSTRAINT price_non_negative CHECK (price_minor >= 0);
-- A deferred constraint trigger asserts each transaction nets to zero
-- at COMMIT, after all its lines are inserted.
CREATE CONSTRAINT TRIGGER transaction_balances
AFTER INSERT OR UPDATE ON ledger_lines
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_transaction_balances();
-- assert_transaction_balances():
-- SELECT SUM(amount_minor) FROM ledger_lines WHERE transaction_id = NEW.transaction_id;
-- IF <> 0 THEN RAISE EXCEPTION 'transaction % does not balance', NEW.transaction_id;Definition
Rounding and allocation
When a value must be split — tax, discount, revenue share — the remainder is distributed deterministically so the parts always re-sum to the whole. No cent is lost to independent rounding. Allocation is a defined operation, not an ad-hoc `round()` per part.
def allocate(total_minor: int, weights: list[int]) -> list[int]:
"""Split total_minor across weights so the result sums back to total_minor."""
w = sum(weights)
base = [total_minor * x // w for x in weights]
remainder = total_minor - sum(base) # the cents rounding dropped
# Hand the leftover cents to the largest fractional parts, deterministically.
order = sorted(range(len(weights)),
key=lambda i: (total_minor * weights[i]) % w, reverse=True)
for i in order[:remainder]:
base[i] += 1
assert sum(base) == total_minor # invariant, always holds
return baseInvariants this spec guarantees
- No monetary value is ever represented or computed as a floating-point number.
- Every transaction's ledger lines sum to exactly zero; an unbalanced transaction cannot commit.
- A split value's parts always re-sum to the original; rounding never creates or destroys a cent.
- Prices and balances cannot be persisted negative where the domain forbids it.