14 September 2026 · 6 min read

How I model currency in a wholesale ERP so the books balance to the third decimal: an integer value object, explicit rounding, and a database column that can't lie.

ERPPostgreSQLTypeScriptFinance

The first thing I decided on the garments ERP was not the framework or the database. It was that no monetary value would ever be a JavaScript number. Everything else followed from that.

The business trades in Omani Rial, which has three decimal places: 1 rial is 1,000 baisa. It buys stock in Bangladeshi Taka. It sells wholesale, so a single invoice line can be 2,400 units at 0.375 rial. If you do that arithmetic in floating point you will, eventually, produce an invoice that is off by one baisa, and then a ledger that doesn't balance, and then an accountant who doesn't trust the system. Nobody trusts a system that is approximately right about money.

Hold money as an integer

The Money value object holds a bigint count of baisa. Addition, subtraction and multiplication by an integer quantity are exact by definition; there is nothing to round. Construction from user input or the database goes through Decimal.js and rounds half-up once, explicitly, at the boundary.

src/common/money/money.ts (the core of it)
export class Money {
  /** Integer baisa. 12.345 OMR -> 12345n */
  private readonly baisa: bigint;
  static readonly SCALE = 3;

  static fromOmr(value: string | number | Decimal): Money {
    const dec = new Decimal(value).mul(1000)
      .toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
    return new Money(BigInt(dec.toFixed(0)));
  }

  add(other: Money): Money { return new Money(this.baisa + other.baisa); }

  /** unit price × quantity. Stays exact. */
  multiplyByQty(qty: number): Money {
    if (!Number.isInteger(qty)) throw new Error(`Quantity must be an integer, received ${qty}`);
    return new Money(this.baisa * BigInt(qty));
  }

  /** VAT, discounts. Rounds half-up back to baisa, once. */
  multiplyByRate(rate: string | number | Decimal): Money {
    const dec = new Decimal(this.baisa.toString()).mul(new Decimal(rate))
      .toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
    return new Money(BigInt(dec.toFixed(0)));
  }

  /** "12.345": canonical string for a NUMERIC(18,3) column. */
  toOmrString(): string { return this.toDecimal().toFixed(Money.SCALE); }
}

Two details matter more than they look. multiplyByQty refuses a non-integer quantity: if someone tries to sell 2.5 units the bug surfaces at the call site, not as a rounding artefact three tables away. And there is no constructor from a number: you cannot accidentally do new Money(12.345). The only doors in are fromOmr and fromBaisa, and both are explicit about what they accept.

Make the database agree

The column type is NUMERIC(18,3), not DOUBLE PRECISION and not an integer of baisa. That is a deliberate compromise: an integer column would be purest, but every report, every ad-hoc query an accountant runs in Prisma Studio, and every CSV export would then show 12345 where a human expects 12.345. NUMERIC(18,3) is exact, and it is legible. Money.toOmrString produces exactly the string it stores, so the round trip is lossless.

Cost is per lot, not per product

Exact money only gets you exact prices. Exact margin needs exact cost, and the same SKU is bought at different prices in different shipments: different Taka price, different exchange rate on the day, different freight. Storing one average cost per product throws that information away.

So every shipment receipt creates a StockLot with its own landed cost, and a sale consumes lots oldest-first and records the cost it took from each one. Cost of goods sold on a sale is a sum of real numbers from real lots. Gross margin is a fact, not an estimate.

Never edit history

Every financial action posts a double-entry LedgerTransaction: debits equal credits against a seeded chart of accounts. Ledgers, confirmed sales, invoices and stock movements are append-only. When something is wrong, the fix is a reversing record, a new row that undoes the old one, never an UPDATE. That is the difference between books an auditor can replay from day one and a database that merely has the right totals today.

  • Balances are derived from transactions, never stored in a column that can drift.
  • Exchange rate is captured on the shipment, so landed cost is fixed at receipt.
  • ESLint treats any as an error across the codebase. A monetary value with type any is a float waiting to happen.

What it costs

More code, honestly. Every service that touches money goes through the value object; every report sums lots instead of reading a cached balance. It is perhaps a week of extra work across the project. Against that: the client has never once opened the system and found a number that disagreed with another number. For an ERP, that is the entire product.

The product this comes fromWholesale Garments ERP

Have a product to build, or one that needs to work better?

Two or three sentences is enough. I'll come back with what I'd do, how long it takes and what it costs.