Skip to content

Money

A monetary value in Arda is never just an amount. Every figure that represents money carries a currency alongside it. This invariant is encoded in the type system from the lowest-level value object (Money.Value) up through the highest-level aggregate (GeneralizedMoney), preventing the silent USD-collapse bugs that plagued earlier versions of the platform.

Money is a value object whose two fields — value and currency — are inseparable. Storing or transmitting a bare number without its currency is a domain error at every layer of the platform.

The canonical Kotlin type is cards.arda.common.lib.domain.general.Money, a sealed interface with a single concrete variant Money.Value:

@Serializable
sealed interface Money : GeneralizedMoney {
@Serializable
data class Value(val value: BigDecimal, val currency: Currency) : Money
}

The TypeScript mirror in src/types/domain.ts follows the same structure:

export interface Money {
kind: 'money';
value: number;
currency: Currency;
}

Plain Money covers the single-currency case. The moment arithmetic spans two different currencies, the result is no longer expressible as a single Money; it is a collection of per-currency amounts. The GeneralizedMoney sealed interface captures all three possible outcomes of monetary arithmetic in one closed type:

VariantKotlin typeMeaning
Single-currencyMoney.ValueOne amount in one currency
Multi-currencyMultiMoneyOne amount per currency, distinct currencies
Additive identityZeroMoneyNo amount; the identity for addition

The algebra is total: every + and - operation over any pair of GeneralizedMoney values returns another GeneralizedMoney. There is no failure mode for currency mismatch — the type system widens to MultiMoney automatically.

The combination rules are:

  • Money(c) + Money(c)Money (same currency collapses)
  • Money(c1) + Money(c2) with c1 ≠ c2MultiMoney with two entries
  • MultiMoney + MoneyMultiMoney (per-currency aggregated)
  • MultiMoney + MultiMoneyMultiMoney (per-currency aggregated)
  • x + ZeroMoneyx (identity)
  • x + negate(x)ZeroMoney (inverse)

The GeneralizedMoney sealed interface exposes:

@Serializable
sealed interface GeneralizedMoney {
operator fun plus(other: GeneralizedMoney): GeneralizedMoney
operator fun minus(other: GeneralizedMoney): GeneralizedMoney
fun negate(): GeneralizedMoney
}

The class hierarchy below shows the three variants and their relationships. Money.Value (rendered as MoneyValue in the diagram for PlantUML compatibility) is the concrete single-currency type; MultiMoney holds a sorted list of Money.Value entries; ZeroMoney is the private identity object.

PlantUML diagram

A MultiMoney holds at most one entry per currency. Adding two amounts in the same currency always collapses to a single Money.ValueMultiMoney is not a list of transactions but a per-currency ledger. Same-currency entries inside a MultiMoney collapse immediately on construction.

A MultiMoney is never empty. An empty sum is ZeroMoney. A single-currency sum is Money.Value.

The canonical currency set is a closed enum. Accepting a value outside this set at any ingress boundary is a validation error; it is not silently normalized to a default.

CodeNameISO 4217
USDUS Dollar840
CADCanadian Dollar124
EUREuro978
GBPPound Sterling826
JPYJapanese Yen392
AUDAustralian Dollar36
CNYChinese Yuan156
INRIndian Rupee356
RUBRussian Ruble643
BRLBrazilian Real986
ZARSouth African Rand710
MXNMexican Peso484
KRWSouth Korean Won410
SGDSingapore Dollar702
HKDHong Kong Dollar344
NZDNew Zealand Dollar554
CHFSwiss Franc756
AEDUAE Dirham784

AED was added in common-module 12.1.0 / system-proto v1.0.0 as part of the PDEV-662 platform-wide currency expansion.

The AED symbol د.إ is an Arabic-script glyph. The platform does not yet support RTL layout; the symbol is rendered in LTR context as the prefix to the value (د.إ7.50). PDF output substitutes the ISO code (AED 7.50) because jsPDF’s bundled Helvetica cannot draw Arabic glyphs — see the PDF rendering section below. Re-evaluate if AED traffic grows and UX requests full RTL rendering.

All Money.Value instances normalize to 12 decimal places (MONEY_PRECISION = 12) using HALF_EVEN (banker’s) rounding at construction time. Callers do not need to pre-round their inputs, but they should be aware that the value stored in a Money.Value is the canonical 12-decimal representation.

This precision contract applies uniformly:

  • at domain-object construction in Kotlin
  • at persistence (stored value is already normalized)
  • at the REST wire layer (producers serialize the normalized value; consumers do not re-round)
  • at the proto layer (the money.proto Money message documents the same contract)

The 12-decimal precision is deliberately higher than any display need (typically 2–4 places); display formatting truncates or rounds further as appropriate for the target currency.

Unknown or unrecognized currency codes are rejected at ingress. This applies to:

  • CSV import rows via ItemCsvUploadService — a row carrying an unrecognized currency code in its CurrencyMessage proto field is rejected with a structured per-row AppError.ArgumentValidation("currency", "unsupported or missing currency code").
  • REST POST bodies — any payload with an unknown currency string fails validation before reaching the service layer.
  • Frontend mapping (mapCurrency in ardaMappers.ts) — throws Error("unsupported currency: " + raw) rather than silently defaulting to USD.

Reads are not subject to hard-fail. Rows already persisted with a currency value that was valid at write time continue to be surfaced unchanged. Hard-fail is an ingress-only gate so that historical data is never silently corrupted by a schema tightening.

The platform’s formatMoney function renders a GeneralizedMoney as a human-readable string. The format is symbol-only: the leading currency symbol is included as a visual cue, but the trailing ISO 4217 code is not appended. The currency code is carried by surrounding UI context (column headers, row state, the per-line currency editor’s trigger label) where it is needed for disambiguation.

Input typeExample output
Money.Value (USD)"$12.34"
Money.Value (EUR)"€5.50"
Money.Value (AED)"د.إ7.50"
MultiMoney (USD + EUR)"$10.00, €5.50"
ZeroMoney"0.00"

MultiMoney renders as a comma-separated list of its single-currency entries. The ordering of entries in a MultiMoney is alphabetical by currency code (ASCII), both on the wire and in the rendered output — the display layer does not re-sort, and end users do not control or override the ordering. There is no currency grouping and no locale-specific separator.

Several ISO 4217 currencies share a symbol — $ for both USD and MXN, ¥ for both JPY and CNY. A symbol-only display therefore leaves a two-row sequence like $50.00 followed by $50.00 visually ambiguous when the rows are in different currencies. The disambiguation strategy splits by surface:

  • Input controls (Order Sheet Cost/Ea cell, TFD mode-and-currency combobox): the trigger renders the ISO code (USD, MXN, AED), not the symbol. A user changing a line’s currency always sees the code change, never a no-op. The dropdown options still carry the <CODE> (<symbol>) label for visual recognition at selection time. This is the input-side fix shipped with PDEV-703.
  • Display surfaces (rendered Money cells, Subtotals, Grand Total, PDF preview): adding a disambiguation chip / ISO code prefix alongside the symbol — only for currencies whose symbol is shared — is tracked in PDEV-708. Until that lands, formatMoney’s JSDoc carries an explicit note about the residual ambiguity.

PDF generation uses jsPDF’s bundled Helvetica font, which supports Latin-1 plus the glyph but not the Arabic glyph in د.إ or the rupee, rouble, and won glyphs in / / . To avoid garbage-glyph fallbacks (which historically also corrupted the character spacing of the surrounding text and caused totals to bleed past the right margin), the PDF generator uses a Helvetica-safe variant of the formatter:

CurrencyUI renderPDF render
USD$12.34$12.34
EUR€5.50€5.50
AEDد.إ7.50AED 7.50
INR₹5.00INR 5.00
MultiMoney (USD + AED)"$10.00, د.إ7.50""$10.00, AED 7.50"

The substitution is mechanical: any symbol containing a character Helvetica cannot draw is replaced by the ISO code with a single-space separator. The UI render is unaffected.

The PDF generator also reserves vertical space for the actual wrapped block height of long money values (e.g. a wide MultiMoney Grand Total) via jsPDF’s splitTextToSize, so right-aligned totals never overflow the bottom or right margin even when the rendered string spans multiple lines.

Money values on transactional input surfaces (OrderSheet Cost/Ea, TFD amount inputs) use step={0.01} — they are cents-rounded at the input control. The item-master unit-price input (ItemFormPanel) keeps step="any" because suppliers can price in fractional units below cents (e.g. $0.125579 per part, per PDEV-748); this is the only surface where sub-cent UI input is permitted. The underlying Money.Value algebra always preserves the full 12-decimal precision regardless of which input surface produced the value.

The algebra is implemented across three layers of the stack. Consult each for implementation detail; this page covers the domain framing.

  • Kotlin: cards.arda.common.lib.domain.general.Money in common-module 12.1.0 — sealed interface hierarchy, operators, Currency enum.
  • Protobuf: cards/arda/v1/domain/common/lang/v1beta1/money.proto in system-proto v1.0.0 — Currency enum (18 entries, explicit field numbers), Money message with the precision note.
  • TypeScript: src/types/domain.ts in arda-frontend-appGeneralizedMoney, MultiMoney, zeroMoney, addMoney, subtractMoney, negateMoney, scalarMultiplyMoney.

For the REST wire representation when GeneralizedMoney crosses an API boundary, see Polymorphic REST DTO.

For the persistence boundary — why MultiMoney is not persisted directly and what the goodsValue column stores — see the operations component knowledge base.


Copyright: (c) Arda Systems 2025-2026, All rights reserved