Skip to content

Value types — money, dates, times

Money, quantities, dates, and times don’t survive being ordinary JavaScript numbers and Dates, so OnERP uses exact, purpose-built value types.

Amounts, prices, rates, and quantities are Decimal — exact base-10 arithmetic, never floating-point, so cents don’t drift. Fields like field.amount, field.decimal, field.rate, and field.quantity carry Decimal values, and you add, multiply, and compare them with its methods — never by coercing to a number.

  • A calendar day (an order date, a validity date) is a Temporal.PlainDate — no time, no time zone. field.date.
  • A moment in time (when a movement was posted) is a Temporal.Instant — a UTC point. field.datetime.

Keeping these separate stops the classic bugs where “a day” silently picks up a time and a zone and lands on the wrong calendar date.

The framework marshals these types across every boundary — database reads and writes, HTTP in and out, validation, equality, form hydration — through one seam: the field kind’s toWire / fromWire pair, which the repository, the wire codec and the Zod schemas all call. Domain code gets a value on the way in and hands one back on the way out; the wire shapes only matter if you write framework code at one of those boundaries. Decimal is a plain numeric string ("1234.56"), PlainDate is yyyy-MM-dd, and Instant is UTC ISO 8601 carrying no subsecond part unless the value has one — "2026-08-18T10:30:00Z", never …:00.000Z, so hand-written JSON or a raw SQL literal that pads the zeros will not match.

Both come from @onerp/meta/values, so domain code never imports big.js or a Temporal polyfill directly.

import { Decimal, Temporal } from "@onerp/meta/values";
const total = lines.reduce((sum, line) => sum.plus(line.amount), new Decimal(0));
const withTax = total.times(rate).round(2);
const due = Temporal.Now.plainDateISO().add({ days: 30 });
const overdue = Temporal.PlainDate.compare(due, today) < 0;

A Decimal is constructed from a string, a number, or another Decimal; arithmetic and comparison go through its methods — .plus, .minus, .times, .div, .neg, .abs; .eq, .gt, .gte, .lt, .lte, .cmp; .round(places, mode?). Coercing to a number to do arithmetic is lossy and is the mistake this type exists to prevent — Number(value) * 0.07 drifts. For decimal-place introspection, use decimalPlaces(value) from @onerp/meta/values. Decimal is big.js under an OnERP name — the big.js documentation is the full method reference, and the rounding modes are constants on the export itself (value.round(2, Decimal.roundDown)).

The framework pins big.js’s globals once: division carries 20 decimal places and rounds half-up, so .div() never expands forever; and the exponential-notation thresholds are widened past any numeric(precision, scale) the framework stores, which is what keeps the wire form plain across the whole range. Column precision is per builder and not shared — field.amount and field.quantity are (18, 6), field.rate is (5, 2), and field.decimal(precision, scale) takes its own.

Temporal values compare with Temporal.PlainDate.compare / Temporal.Instant.compare, which return -1 | 0 | 1 — the JavaScript sort-key convention, so they drop straight into .sort(). Equality is .equals.

To display either type, call the formatters in @onerp/meta/format: formatDate(plainDate, locale, style?), formatDateTime(instant, locale) and formatRelativeTime(instant, locale), alongside formatDecimal and formatMoney for numbers. They live in meta rather than in the browser runtime because they are pure — a value plus a locale string — so the server-side PDF renderer reaches the same ones.

Nothing else constructs an Intl.*Format. The formatters own the projection the pinned polyfill forces (it does not bridge a Temporal value to Intl.DateTimeFormat, which throws when handed one): an Instant via new Date(instant.epochMilliseconds), exact and rendered in the viewer’s zone; a PlainDate field by field (new Date(plain.year, plain.month - 1, plain.day)) so no zone math runs. React code normally reaches <FieldValue> instead, which routes to the same functions.

The locale is always passed in. In the browser it comes from useLocale(), so a date follows the locale the user chose in settings rather than the one their browser reports. A document that must not follow the reader — a German invoice — passes its own locale explicitly; packages/core/src/pdf/letter/formats.ts is the example.

Locale-free formatting (toISOString, Decimal.toFixed, Temporal.toString()) is for serialization seams only: the wire, cursors, CSV, document numbers, and the value of a native <input type="date">. Never for text a person reads.