Saltar al contenido principal

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

Concernmoney_minor (old)numeric(20,4) (new)
Readability1234 = $12.34 — non-obvious12.3400 — self-evident
Multi-currencyRequires per-call minorUnit lookups4dp covers JPY (0), USD (2), KWD (3), CLF (4)
FEL / SAT complianceInteger → divide by 100 at serialize timeDirect .toFixed(2) for amounts, .toFixed(6) for rates
Call-site complexity~185 backend + ~149 PWA toMinor/toMajor callsZero conversion helpers

Rules

  1. Store as string — Kysely types numeric columns as string. Never coerce to JS number for money math (float drift).
  2. All arithmetic via decimal.js — use new Decimal(value), never Number() / parseFloat() / +.
  3. Write to DB with .toFixed(4) — use toMoneyString() from @flowpos-workspace/global/utils/money.
  4. Display with formatCurrency(amount, symbol, minorUnit) from apps/frontend-pwa/src/utils/money.ts. minorUnit is the ISO 4217 display precision (2 for GTQ/USD), not the storage precision.
  5. 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.
  6. New migrations must not use money_minor — the scripts/lint-no-money-minor.mjs guard 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.ts
  • apps/frontend-pwa/src/main.tsx
  • apps/backend/jest.config.ts (via setupFiles)
  • apps/frontend-pwa/vitest.config.ts (via setupFiles)

Helpers (packages/global/src/utils/money.ts)

HelperPurpose
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):

TableColumns
order_itemunit_price_snapshot, tax_amount_snapshot
order_billsubtotal, tax_amount, tip_amount, total
order_bill_paymentamount
order_paymentamount
product_modifierprice_adjustment
order_item_modifierunit_price_adjustment, total_price_adjustment
inventory_ledgerunit_price, amount, base_amount (formerly _minor twins dropped)
price_list_itemunit_price (formerly unit_price_minor dropped)