Money Representation
Decision
All monetary amounts are stored as numeric(20,4) (PostgreSQL) — major-unit decimal strings with 4 fractional digits (e.g. "12.3400").
The legacy money_minor PostgreSQL DOMAIN (bigint integer cents) was removed in migration 2026-04-16t00-00-00-drop-money-minor-domain.mjs.
Rationale
| Concern | money_minor (old) | numeric(20,4) (new) |
|---|---|---|
| Readability | 1234 = $12.34 — non-obvious | 12.3400 — self-evident |
| Multi-currency | Requires per-call minorUnit lookups | 4dp covers JPY (0), USD (2), KWD (3), CLF (4) |
| FEL / SAT compliance | Integer → divide by 100 at serialize time | Direct .toFixed(2) for amounts, .toFixed(6) for rates |
| Call-site complexity | ~185 backend + ~149 PWA toMinor/toMajor calls | Zero conversion helpers |
Rules
- Store as string — Kysely types
numericcolumns asstring. Never coerce to JSnumberfor money math (float drift). - All arithmetic via
decimal.js— usenew Decimal(value), neverNumber()/parseFloat()/+. - Write to DB with
.toFixed(4)— usetoMoneyString()from@flowpos-workspace/global/utils/money. - Display with
formatCurrency(amount, symbol, minorUnit)fromapps/frontend-pwa/src/utils/money.ts.minorUnitis the ISO 4217 display precision (2 for GTQ/USD), not the storage precision. - Payment gateway webhooks — gateways (Stripe, etc.) send amounts as integer minor units. Convert at the boundary:
new Decimal(event.amount).div(100).toFixed(4)before writing. - New migrations must not use
money_minor— thescripts/lint-no-money-minor.mjsguard enforces this in CI.
decimal.js configuration
Set once at each entry point via import "@flowpos-workspace/global/utils/money-config":
Decimal.set({ rounding: Decimal.ROUND_HALF_UP, precision: 30 });
ROUND_HALF_UP matches Guatemala SAT/FEL commercial invoicing expectations.
precision: 30 provides headroom above numeric(20,4) for intermediate tax-percentage chain math.
Entry points where this is imported:
apps/backend/src/main.tsapps/frontend-pwa/src/main.tsxapps/backend/jest.config.ts(viasetupFiles)apps/frontend-pwa/vitest.config.ts(viasetupFiles)
Helpers (packages/global/src/utils/money.ts)
| Helper | Purpose |
|---|---|
d(v) | Wrap value in Decimal |
toMoneyString(d) | .toFixed(4) — use for all DB writes |
sumMoney(values) | Sum an array of money values |
addMoney(a, b) | Add two money values |
subtractMoney(a, b) | Subtract b from a |
multiplyMoney(a, factor) | Multiply (e.g. quantity × price) |
roundMoney(v) | Round to 4dp (ROUND_HALF_UP) |
toDisplayMoney(v, dp) | Round to display precision (2 for GTQ) |
fromGatewayMinor(cents) | Gateway integer cents → major-unit string |
cost_dec coexistence (numeric(20,6))
The cost_dec PostgreSQL domain is numeric(20,6) — 2 more fractional digits than numeric(20,4). It is used for WAC/FIFO unit costs (inventory_ledger.unit_cost, product.cost, product.base_cost).
Rule at mixed-math boundaries (e.g., margin = price − cost): always normalize to numeric(20,4) precision after the subtraction, not before:
const margin = new Decimal(price).minus(new Decimal(cost)).toDecimalPlaces(4);
Rounding a cost_dec value to 4dp before subtracting can introduce a rounding error of up to 0.000050 per unit. Round the result, not the operands.
Column inventory (migrated 2026-04-16)
Columns converted from money_minor to numeric(20,4):
| Table | Columns |
|---|---|
order_item | unit_price_snapshot, tax_amount_snapshot |
order_bill | subtotal, tax_amount, tip_amount, total |
order_bill_payment | amount |
order_payment | amount |
product_modifier | price_adjustment |
order_item_modifier | unit_price_adjustment, total_price_adjustment |
inventory_ledger | unit_price, amount, base_amount (formerly _minor twins dropped) |
price_list_item | unit_price (formerly unit_price_minor dropped) |