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.
Money and quantities are Decimal
Section titled “Money and quantities are Decimal”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.
Dates and times are Temporal
Section titled “Dates and times are Temporal”- 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.
You never see the wire format
Section titled “You never see the wire format”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.
Working with them
Section titled “Working with them”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.