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.
The Money Concept
Section titled “The Money Concept”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:
@Serializablesealed 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;}The GeneralizedMoney Algebra
Section titled “The GeneralizedMoney Algebra”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:
| Variant | Kotlin type | Meaning |
|---|---|---|
| Single-currency | Money.Value | One amount in one currency |
| Multi-currency | MultiMoney | One amount per currency, distinct currencies |
| Additive identity | ZeroMoney | No 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)withc1 ≠ c2→MultiMoneywith two entriesMultiMoney + Money→MultiMoney(per-currency aggregated)MultiMoney + MultiMoney→MultiMoney(per-currency aggregated)x + ZeroMoney→x(identity)x + negate(x)→ZeroMoney(inverse)
The GeneralizedMoney sealed interface exposes:
@Serializablesealed 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.
MultiMoney Semantics
Section titled “MultiMoney Semantics”A MultiMoney holds at most one entry per currency. Adding two amounts in the same currency always collapses to a single Money.Value — MultiMoney 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 18-Currency Catalog
Section titled “The 18-Currency Catalog”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.
| Code | Name | ISO 4217 |
|---|---|---|
| USD | US Dollar | 840 |
| CAD | Canadian Dollar | 124 |
| EUR | Euro | 978 |
| GBP | Pound Sterling | 826 |
| JPY | Japanese Yen | 392 |
| AUD | Australian Dollar | 36 |
| CNY | Chinese Yuan | 156 |
| INR | Indian Rupee | 356 |
| RUB | Russian Ruble | 643 |
| BRL | Brazilian Real | 986 |
| ZAR | South African Rand | 710 |
| MXN | Mexican Peso | 484 |
| KRW | South Korean Won | 410 |
| SGD | Singapore Dollar | 702 |
| HKD | Hong Kong Dollar | 344 |
| NZD | New Zealand Dollar | 554 |
| CHF | Swiss Franc | 756 |
| AED | UAE Dirham | 784 |
AED was added in common-module 12.1.0 / system-proto v1.0.0 as part of the PDEV-662 platform-wide currency expansion.
AED Display Note
Section titled “AED Display Note”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.
Precision Contract
Section titled “Precision Contract”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.protoMoneymessage 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.
Ingress Hard-Fail Discipline
Section titled “Ingress Hard-Fail Discipline”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 itsCurrencyMessageproto field is rejected with a structured per-rowAppError.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 (
mapCurrencyinardaMappers.ts) — throwsError("unsupported currency: " + raw)rather than silently defaulting toUSD.
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.
Display Rules
Section titled “Display Rules”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 type | Example 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.
Same-symbol ambiguity
Section titled “Same-symbol ambiguity”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/Eacell, 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 rendering
Section titled “PDF rendering”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:
| Currency | UI render | PDF render |
|---|---|---|
| USD | $12.34 | $12.34 |
| EUR | €5.50 | €5.50 |
| AED | د.إ7.50 | AED 7.50 |
| INR | ₹5.00 | INR 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-input semantics
Section titled “Money-input semantics”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.
Cross-References
Section titled “Cross-References”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.Moneyincommon-module12.1.0 — sealed interface hierarchy, operators,Currencyenum. - Protobuf:
cards/arda/v1/domain/common/lang/v1beta1/money.protoinsystem-protov1.0.0 —Currencyenum (18 entries, explicit field numbers),Moneymessage with the precision note. - TypeScript:
src/types/domain.tsinarda-frontend-app—GeneralizedMoney,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.
See Also
Section titled “See Also”- Value Objects — conventions for declaring value objects in Kotlin.
- Primitives — scalar types that underpin
Money.Value. - Polymorphic REST DTO — how
GeneralizedMoneyDtocrosses REST boundaries.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved