Value objects: money & units
Store every quantity once, in one canonical form, and convert only when shown or
exported. Money is integer minor units plus a currency; physical quantities are
whole SI base units; time is UTC. This is what lets a user flip any display
preference (currency symbol, unit system, locale) without corrupting one stored
row, and lets "sum of parts == whole" be structural, not hoped-for.
This core is pure Dart — no flutter/*, no intl, no dart:io, no plugins.
Formatting and digit normalization happen upstream/downstream, not here. These
value objects live in lib/core/, the sanctioned pure-foundation layer (see
project-structure-and-packages) — never a utils//common//shared/ grab-bag.
Read the reference for the task at hand:
references/allocate-and-splitting.md — the largest-remainder allocate()
primitive, its invariants, the two-rounding-sites trap, edge policies, verified
test vectors, and the subtotal→tax→tip split pipeline.
references/canonical-storage.md — the ISO-4217 exponent rule, the SI unit
tables, decimal-based parsing, the cents-accumulator for keypad input,
rounding discipline, and the Clock-injected dated-rate / staleness engine.
references/domain-model.md — value-type modelling: relationships as id links,
derive-don't-store, one currency per aggregate, immutable state with value
equality.
Run scripts/check-money-violations.sh and scripts/verify-core.sh before a PR.
Non-negotiable rules
- Money is
int minor units + a Currency — NEVER double/num/REAL.
Binary floats cannot represent 0.01; drift silently corrupts totals the user
can never re-derive. No money API accepts or returns double.
- Derive minor-units-per-major from the currency's real ISO-4217 exponent —
NEVER hardcode
* 100, / 100, or "2 decimals". Exponent is 0 for JPY/VND,
2 for USD/EUR, 3 for KWD/BHD/OMR. A hardcoded 100 is a 100× error for a
0-exponent currency and a 10× error for a 3-exponent one. Route through
currency.minorPerMajor.
- Unknown currency code is a typed failure, never a silent default-to-2. The
exponent table lists only shipped currencies;
Currency.tryParse returns null
and the caller emits a Failure.
- One currency per aggregate; cross-currency arithmetic is forbidden. Adding
two
Money of different currencies is a category error — throw
(programmer error) or convert through the FX layer first. Keep currency a
fact of the enclosing aggregate so Money arithmetic never has to guard it.
- Route EVERY division of money through one
allocate(amount, weights)
primitive. Shared items, tax proration, tip proration, discounts — all one
rounding path, so "parts sum to the whole to the exact minor unit" is proven
once and tested once.
- There are TWO rounding sites, not one:
allocate() AND percent→minor-units.
Round a percentage to integer minor units once before feeding allocate().
A naive double percent is a classic off-by-a-cent bug that allocate
coverage will not catch.
- Never sum independently-rounded parts to get a total. Always
allocate() a
known integer total and let the parts absorb the residual.
- Derive totals; never store them. A denormalized stored total is the classic
drift bug. Totals are computed from items + weights on read.
- Model relationships as stable-
id links, not embedded copies. Give every
entity an explicit final id (e.g. a UUID). Editing a price then leaves
assignments intact and deleting a participant just drops them from link sets.
- Store canonically, convert at the edge. Physical quantities are whole SI
base units (
int metres / millilitres / minutes); time is a UTC DateTime.
.from<DisplayUnit> factories round into canonical; to<DisplayUnit>()
getters return a double used only at the presentation edge.
- Normalize digits/separators to ASCII BEFORE input reaches this core. Never
call
int.parse/double.parse/Decimal.parse on raw localized input — it
throws on Eastern-Arabic numerals. Fold upstream (see i18n-rtl-l10n).
- Parse with
decimal; round ONCE with an explicit mode at the boundary.
Use package:decimal for exact division/parsing, apply an explicit
RoundingMode (default half-even/banker's) once at the parse or final-total
boundary — never on intermediate sums (accumulates bias).
- Inject
package:clock's Clock; NEVER call DateTime.now(), never roll a
bespoke ClockService. Every time-reading class in this pure core takes a
Clock constructor arg; Riverpod/feature code injects the same Clock
through a clockProvider (see service-boundary-and-native) so the two
vocabularies compose. Fixed clocks / fake_async then make time-dependent
logic deterministic in tests.
- The core stays Flutter-free and IO-free. Deps are only
decimal and
clock. Formatting lives in the presentation layer; storage in the data layer.
Canonical storage
Widgets and repositories exchange value objects, never raw ints or strings.
| Quantity |
Canonical storage |
Type |
Never store |
| Money |
integer minor units + Currency |
int |
double, formatted string |
| Distance |
whole metres |
int |
km, miles |
| Volume |
whole millilitres |
int |
litres, gallons |
| Duration |
whole minutes (or Duration) |
int |
hours as double |
| Timestamp |
UTC ISO-8601 instant |
DateTime (UTC) |
local time |
/// ISO-4217 minor-unit exponents. Explicit table — NEVER default to 2.
enum Currency {
jpy('JPY', 0), vnd('VND', 0),
usd('USD', 2), eur('EUR', 2), gbp('GBP', 2),
kwd('KWD', 3), bhd('BHD', 3), omr('OMR', 3);
const Currency(this.code, this.exponent);
final String code;
final int exponent;
/// 10^exponent — minor units per major unit. The ONLY scaling source.
int get minorPerMajor => switch (exponent) {
0 => 1,
2 => 100,
3 => 1000,
_ => throw StateError('unsupported exponent $exponent for $code'),
};
static Currency? tryParse(String code) {
// Plain loop, not `firstOrNull` (a package:collection extension) — keeps the
// core dependency-free beyond `decimal`.
for (final c in Currency.values) {
if (c.code == code) return c;
}
return null;
}
}
/// Money is (integer minor units) + (currency). No floats, ever.
final class Money implements Comparable<Money> {
const Money(this.minorUnits, this.currency);
final int minorUnits; // e.g. 12345 with KWD == 12.345 KWD
final Currency currency;
Money operator +(Money o) => currency == o.currency
? Money(minorUnits + o.minorUnits, currency)
: throw ArgumentError('currency mismatch: $currency vs ${o.currency}');
@override
int compareTo(Money o) {
assert(currency == o.currency, 'compare across currencies is a bug');
return minorUnits.compareTo(o.minorUnits);
}
@override
bool operator ==(Object o) =>
o is Money && o.minorUnits == minorUnits && o.currency == currency;
@override
int get hashCode => Object.hash(minorUnits, currency);
}
Physical value objects follow the identical shape — an integer canonical field,
rounding .from<Unit> factories, and edge-only to<Unit>() getters. See
references/canonical-storage.md and examples/money.dart.
The one division path: allocate()
Every time money is split, it goes through this integer largest-remainder
(Hamilton) primitive. Never divide money any other way.
/// Splits [amount] minor units across [weights], guaranteeing the parts sum
/// EXACTLY to [amount]. Deterministic ascending-index tie-break; residual < n.
/// Negative amount mirrors and negates (discounts/refunds); zero weight-sum
/// falls back to equal weights; empty weights returns [] — money math must
/// never throw into the UI.
List<int> allocate(int amount, List<int> weights) {
final n = weights.length;
if (n == 0) return const [];
if (amount < 0) return allocate(-amount, weights).map((s) => -s).toList();
final sanitized = [for (final w in weights) w < 0 ? 0 : w];
final weightSum = sanitized.fold(0, (a, b) => a + b);
final w = weightSum == 0 ? List.filled(n, 1) : sanitized;
final total = weightSum == 0 ? n : weightSum;
final shares = List<int>.filled(n, 0);
final remainders = <({int remainder, int index})>[];
var distributed = 0;
for (var i = 0; i < n; i++) {
final product = amount * w[i]; // multiply FIRST, then ~/ and % — no float
final floorShare = product ~/ total;
shares[i] = floorShare;
distributed += floorShare;
remainders.add((remainder: product % total, index: i));
}
var leftover = amount - distributed; // always in 0 ..< n
remainders.sort((a, b) => a.remainder != b.remainder
? b.remainder.compareTo(a.remainder)
: a.index.compareTo(b.index));
for (var k = 0; leftover > 0; k++, leftover--) {
shares[remainders[k].index] += 1;
}
assert(shares.fold(0, (a, b) => a + b) == amount, 'allocate must be exact');
return shares;
}
Verified vectors (assert these in a test):
allocate(1001,[1,1,1]) == [334,334,333] ·
allocate(660,[1584,4033,1933]) == [138,353,169] ·
allocate(1510,[1584,4033,1933]) == [317,807,386].
The split pipeline layers allocate() in passes — item subtotals, then
allocate(taxMinor, subtotals), then allocate(tipMinor, subtotals) — so every
whole-bill figure is distributed and the per-participant finals sum to the grand
total exactly. Full pipeline, edge policies, and the percent→minor-units rounding
site are in references/allocate-and-splitting.md and examples/allocate.dart.
Parsing input to exact minor units
Input must already be ASCII-normalized upstream. Parse with decimal (no binary
error), scale by the currency's exponent, round once.
/// Caller MUST have normalized digits + separators to ASCII first.
Money moneyFromMajorString(String ascii, Currency c) {
final scaled = (Decimal.parse(ascii) * Decimal.fromInt(c.minorPerMajor))
.round(); // exact; Decimal has no binary-float error
return Money(scaled.toBigInt().toInt(), c);
}
For a numeric keypad with no fixed decimal key, accumulate digits into minor
units directly and never touch a locale decimal separator — see
references/canonical-storage.md.
Derive, don't store; inject a Clock
Totals are computed on read from items + weights; a stored total is a drift bug.
Any time-dependent value object (a dated rate, a staleness band, an expiry) takes
an injected Clock so tests are deterministic.
final class RateSnapshot {
const RateSnapshot(this._clock);
final Clock _clock;
int ageDays(DateTime asOfUtc) => _clock.now().difference(asOfUtc).inDays;
}
// prod: RateSnapshot(const Clock()); test: RateSnapshot(Clock.fixed(fixedUtc));
Anti-patterns
double amount / num price fields. Cannot represent 0.01; corrupts
totals irreversibly. Use int minor units.
amount * 100 / cents / 100. Wrong for every non-2-exponent currency.
Route through currency.minorPerMajor.
- Defaulting an unknown currency to 2 decimals. Silently mis-scales. Return a
typed failure.
- Adding
Money across currencies, or storing a currency per line item when the
whole aggregate is single-currency. Convert at the FX boundary; keep currency
at the aggregate level.
- Summing independently-rounded shares to produce a total. Rounds twice and
drifts.
allocate() a known integer total instead.
- Recomputing tax/tip from a percentage inside the allocation loop. Round the
percent to integer minor units once, then
allocate().
- Storing a denormalized
total on the entity. Derive it.
DateTime.now(), or a hand-rolled ClockService/SystemClock/FakeClock,
inside the pure core. Untestable / non-composable time. Inject
package:clock's Clock.
Decimal(someDouble) or int.parse on raw localized input. Binary error /
throws on Eastern digits. Parse Decimal from a String; normalize first.
- Rounding intermediate sums. Accumulates bias. Round once at the boundary.
Definition of done
Related skills
- See
project-structure-and-packages for where this pure core lives (lib/core/,
the sanctioned foundation layer) within the feature-first app layout.
- See
service-boundary-and-native for injecting this Clock into Riverpod
code via clockProvider (the same package:clock seam, provider-wired).
- See
error-handling-typed-results for the sealed Result<T, F extends Failure>
spine that parsing and FX return instead of throwing.
- See
i18n-rtl-l10n for ASCII digit/separator normalization and currency
formatting at the presentation edge (kept out of this pure core).
- See
dart3-idioms-and-coding-standards for immutable value types, sealed types,
and total non-throwing domain functions.
- See
persistence-drift for storing minor units + ISO code (never a REAL) and
mapping rows to these value objects.
- See
testing-strategy for the clock-injected, table-driven unit tests these
pure functions demand.
References
1---2name: value-objects-money-and-units3description: Enforces a pure-Dart value-object core that stores every quantity canonically — money as integer minor units keyed to each currency's real ISO-4217 exponent (never *100), physical amounts as SI whole units, timestamps as UTC — and converts only at the presentation edge; forbids double/num money, cross-currency arithmetic, and defaulting an unknown currency to two decimals; routes every division of money through one largest-remainder allocate() primitive so parts always sum to the whole to the exact minor unit; derives totals instead of storing them, links entities by stable id, and injects a Clock instead of DateTime.now. Use when defining or changing Money, Currency, or a unit value object; parsing or formatting an amount; splitting, prorating, discounting, tax/tip, or distributing money; adding a currency or FX rate; converting quantities; or fixing float-money, hardcoded-100, cross-currency, off-by-a-cent, or stored-total-drift bugs.4---56# Value objects: money & units78Store every quantity **once, in one canonical form, and convert only when shown or9exported**. Money is integer minor units plus a currency; physical quantities are10whole SI base units; time is UTC. This is what lets a user flip any display11preference (currency symbol, unit system, locale) without corrupting one stored12row, and lets "sum of parts == whole" be structural, not hoped-for.1314This core is **pure Dart** — no `flutter/*`, no `intl`, no `dart:io`, no plugins.15Formatting and digit normalization happen upstream/downstream, not here. These16value objects live in `lib/core/`, the sanctioned pure-foundation layer (see17`project-structure-and-packages`) — never a `utils/`/`common/`/`shared/` grab-bag.1819Read the reference for the task at hand:20- `references/allocate-and-splitting.md` — the largest-remainder `allocate()`21 primitive, its invariants, the two-rounding-sites trap, edge policies, verified22 test vectors, and the subtotal→tax→tip split pipeline.23- `references/canonical-storage.md` — the ISO-4217 exponent rule, the SI unit24 tables, `decimal`-based parsing, the cents-accumulator for keypad input,25 rounding discipline, and the Clock-injected dated-rate / staleness engine.26- `references/domain-model.md` — value-type modelling: relationships as id links,27 derive-don't-store, one currency per aggregate, immutable state with value28 equality.2930Run `scripts/check-money-violations.sh` and `scripts/verify-core.sh` before a PR.3132## Non-negotiable rules33341. **Money is `int` minor units + a `Currency` — NEVER `double`/`num`/`REAL`.**35 Binary floats cannot represent `0.01`; drift silently corrupts totals the user36 can never re-derive. No money API accepts or returns `double`.372. **Derive minor-units-per-major from the currency's real ISO-4217 exponent —38 NEVER hardcode `* 100`, `/ 100`, or "2 decimals".** Exponent is 0 for JPY/VND,39 2 for USD/EUR, 3 for KWD/BHD/OMR. A hardcoded `100` is a 100× error for a40 0-exponent currency and a 10× error for a 3-exponent one. Route through41 `currency.minorPerMajor`.423. **Unknown currency code is a typed failure, never a silent default-to-2.** The43 exponent table lists only shipped currencies; `Currency.tryParse` returns null44 and the caller emits a `Failure`.454. **One currency per aggregate; cross-currency arithmetic is forbidden.** Adding46 two `Money` of different currencies is a category error — throw47 (programmer error) or convert through the FX layer first. Keep currency a48 fact of the enclosing aggregate so `Money` arithmetic never has to guard it.495. **Route EVERY division of money through one `allocate(amount, weights)`50 primitive.** Shared items, tax proration, tip proration, discounts — all one51 rounding path, so "parts sum to the whole to the exact minor unit" is proven52 once and tested once.536. **There are TWO rounding sites, not one: `allocate()` AND percent→minor-units.**54 Round a percentage to integer minor units **once** before feeding `allocate()`.55 A naive `double` percent is a classic off-by-a-cent bug that `allocate`56 coverage will not catch.577. **Never sum independently-rounded parts to get a total.** Always `allocate()` a58 known integer total and let the parts absorb the residual.598. **Derive totals; never store them.** A denormalized stored total is the classic60 drift bug. Totals are computed from items + weights on read.619. **Model relationships as stable-`id` links, not embedded copies.** Give every62 entity an explicit `final id` (e.g. a UUID). Editing a price then leaves63 assignments intact and deleting a participant just drops them from link sets.6410. **Store canonically, convert at the edge.** Physical quantities are whole SI65 base units (`int` metres / millilitres / minutes); time is a UTC `DateTime`.66 `.from<DisplayUnit>` factories round *into* canonical; `to<DisplayUnit>()`67 getters return a `double` used only at the presentation edge.6811. **Normalize digits/separators to ASCII BEFORE input reaches this core.** Never69 call `int.parse`/`double.parse`/`Decimal.parse` on raw localized input — it70 throws on Eastern-Arabic numerals. Fold upstream (see `i18n-rtl-l10n`).7112. **Parse with `decimal`; round ONCE with an explicit mode at the boundary.**72 Use `package:decimal` for exact division/parsing, apply an explicit73 `RoundingMode` (default half-even/banker's) once at the parse or final-total74 boundary — never on intermediate sums (accumulates bias).7513. **Inject `package:clock`'s `Clock`; NEVER call `DateTime.now()`, never roll a76 bespoke `ClockService`.** Every time-reading class in this pure core takes a77 `Clock` constructor arg; Riverpod/feature code injects the *same* `Clock`78 through a `clockProvider` (see `service-boundary-and-native`) so the two79 vocabularies compose. Fixed clocks / `fake_async` then make time-dependent80 logic deterministic in tests.8114. **The core stays Flutter-free and IO-free.** Deps are only `decimal` and82 `clock`. Formatting lives in the presentation layer; storage in the data layer.8384## Canonical storage8586Widgets and repositories exchange **value objects**, never raw `int`s or strings.8788| Quantity | Canonical storage | Type | Never store |89| --- | --- | --- | --- |90| Money | integer **minor units** + `Currency` | `int` | double, formatted string |91| Distance | whole **metres** | `int` | km, miles |92| Volume | whole **millilitres** | `int` | litres, gallons |93| Duration | whole **minutes** (or `Duration`) | `int` | hours as double |94| Timestamp | UTC **ISO-8601 instant** | `DateTime` (UTC) | local time |9596```dart97/// ISO-4217 minor-unit exponents. Explicit table — NEVER default to 2.98enum Currency {99 jpy('JPY', 0), vnd('VND', 0),100 usd('USD', 2), eur('EUR', 2), gbp('GBP', 2),101 kwd('KWD', 3), bhd('BHD', 3), omr('OMR', 3);102103 const Currency(this.code, this.exponent);104 final String code;105 final int exponent;106107 /// 10^exponent — minor units per major unit. The ONLY scaling source.108 int get minorPerMajor => switch (exponent) {109 0 => 1,110 2 => 100,111 3 => 1000,112 _ => throw StateError('unsupported exponent $exponent for $code'),113 };114115 static Currency? tryParse(String code) {116 // Plain loop, not `firstOrNull` (a package:collection extension) — keeps the117 // core dependency-free beyond `decimal`.118 for (final c in Currency.values) {119 if (c.code == code) return c;120 }121 return null;122 }123}124125/// Money is (integer minor units) + (currency). No floats, ever.126final class Money implements Comparable<Money> {127 const Money(this.minorUnits, this.currency);128 final int minorUnits; // e.g. 12345 with KWD == 12.345 KWD129 final Currency currency;130131 Money operator +(Money o) => currency == o.currency132 ? Money(minorUnits + o.minorUnits, currency)133 : throw ArgumentError('currency mismatch: $currency vs ${o.currency}');134135 @override136 int compareTo(Money o) {137 assert(currency == o.currency, 'compare across currencies is a bug');138 return minorUnits.compareTo(o.minorUnits);139 }140141 @override142 bool operator ==(Object o) =>143 o is Money && o.minorUnits == minorUnits && o.currency == currency;144 @override145 int get hashCode => Object.hash(minorUnits, currency);146}147```148149Physical value objects follow the identical shape — an integer canonical field,150rounding `.from<Unit>` factories, and edge-only `to<Unit>()` getters. See151`references/canonical-storage.md` and `examples/money.dart`.152153## The one division path: `allocate()`154155Every time money is split, it goes through this integer largest-remainder156(Hamilton) primitive. Never divide money any other way.157158```dart159/// Splits [amount] minor units across [weights], guaranteeing the parts sum160/// EXACTLY to [amount]. Deterministic ascending-index tie-break; residual < n.161/// Negative amount mirrors and negates (discounts/refunds); zero weight-sum162/// falls back to equal weights; empty weights returns [] — money math must163/// never throw into the UI.164List<int> allocate(int amount, List<int> weights) {165 final n = weights.length;166 if (n == 0) return const [];167 if (amount < 0) return allocate(-amount, weights).map((s) => -s).toList();168169 final sanitized = [for (final w in weights) w < 0 ? 0 : w];170 final weightSum = sanitized.fold(0, (a, b) => a + b);171 final w = weightSum == 0 ? List.filled(n, 1) : sanitized;172 final total = weightSum == 0 ? n : weightSum;173174 final shares = List<int>.filled(n, 0);175 final remainders = <({int remainder, int index})>[];176 var distributed = 0;177 for (var i = 0; i < n; i++) {178 final product = amount * w[i]; // multiply FIRST, then ~/ and % — no float179 final floorShare = product ~/ total;180 shares[i] = floorShare;181 distributed += floorShare;182 remainders.add((remainder: product % total, index: i));183 }184 var leftover = amount - distributed; // always in 0 ..< n185 remainders.sort((a, b) => a.remainder != b.remainder186 ? b.remainder.compareTo(a.remainder)187 : a.index.compareTo(b.index));188 for (var k = 0; leftover > 0; k++, leftover--) {189 shares[remainders[k].index] += 1;190 }191 assert(shares.fold(0, (a, b) => a + b) == amount, 'allocate must be exact');192 return shares;193}194```195196Verified vectors (assert these in a test):197`allocate(1001,[1,1,1]) == [334,334,333]` ·198`allocate(660,[1584,4033,1933]) == [138,353,169]` ·199`allocate(1510,[1584,4033,1933]) == [317,807,386]`.200201The split pipeline layers `allocate()` in passes — item subtotals, then202`allocate(taxMinor, subtotals)`, then `allocate(tipMinor, subtotals)` — so every203whole-bill figure is distributed and the per-participant finals sum to the grand204total exactly. Full pipeline, edge policies, and the percent→minor-units rounding205site are in `references/allocate-and-splitting.md` and `examples/allocate.dart`.206207## Parsing input to exact minor units208209Input must already be ASCII-normalized upstream. Parse with `decimal` (no binary210error), scale by the currency's exponent, round once.211212```dart213/// Caller MUST have normalized digits + separators to ASCII first.214Money moneyFromMajorString(String ascii, Currency c) {215 final scaled = (Decimal.parse(ascii) * Decimal.fromInt(c.minorPerMajor))216 .round(); // exact; Decimal has no binary-float error217 return Money(scaled.toBigInt().toInt(), c);218}219```220221For a numeric keypad with no fixed decimal key, accumulate digits into minor222units directly and never touch a locale decimal separator — see223`references/canonical-storage.md`.224225## Derive, don't store; inject a Clock226227Totals are computed on read from items + weights; a stored total is a drift bug.228Any time-dependent value object (a dated rate, a staleness band, an expiry) takes229an injected `Clock` so tests are deterministic.230231```dart232final class RateSnapshot {233 const RateSnapshot(this._clock);234 final Clock _clock;235 int ageDays(DateTime asOfUtc) => _clock.now().difference(asOfUtc).inDays;236}237// prod: RateSnapshot(const Clock()); test: RateSnapshot(Clock.fixed(fixedUtc));238```239240## Anti-patterns241242- **`double amount` / `num price` fields.** Cannot represent `0.01`; corrupts243 totals irreversibly. Use `int` minor units.244- **`amount * 100` / `cents / 100`.** Wrong for every non-2-exponent currency.245 Route through `currency.minorPerMajor`.246- **Defaulting an unknown currency to 2 decimals.** Silently mis-scales. Return a247 typed failure.248- **Adding `Money` across currencies, or storing a currency per line item when the249 whole aggregate is single-currency.** Convert at the FX boundary; keep currency250 at the aggregate level.251- **Summing independently-rounded shares to produce a total.** Rounds twice and252 drifts. `allocate()` a known integer total instead.253- **Recomputing tax/tip from a percentage inside the allocation loop.** Round the254 percent to integer minor units once, then `allocate()`.255- **Storing a denormalized `total` on the entity.** Derive it.256- **`DateTime.now()`, or a hand-rolled `ClockService`/`SystemClock`/`FakeClock`,257 inside the pure core.** Untestable / non-composable time. Inject258 `package:clock`'s `Clock`.259- **`Decimal(someDouble)` or `int.parse` on raw localized input.** Binary error /260 throws on Eastern digits. Parse `Decimal` from a `String`; normalize first.261- **Rounding intermediate sums.** Accumulates bias. Round once at the boundary.262263## Definition of done264265- [ ] Every money field is `int` minor units + a `Currency`; no `double`/`num`.266- [ ] All scaling goes through `currency.minorPerMajor`; no literal `100`.267- [ ] Unknown currency returns a typed failure, never a defaulted parse.268- [ ] No cross-currency arithmetic; currency lives at the aggregate level.269- [ ] Every money split calls `allocate()`; conservation asserted and tested with270 the verified vectors.271- [ ] Percentages round to integer minor units once, before `allocate()`.272- [ ] No stored totals; totals derive on read.273- [ ] Entities carry an explicit stable `id`; relationships are id links.274- [ ] Physical quantities stored as SI `int`s; converted only at the edge.275- [ ] Time-reading classes take an injected `Clock`; no `DateTime.now()` in core.276- [ ] Core imports only `decimal` and `clock`; no Flutter/intl/dart:io.277- [ ] `scripts/check-money-violations.sh` and `scripts/verify-core.sh` pass.278279## Related skills280281- See `project-structure-and-packages` for where this pure core lives (`lib/core/`,282 the sanctioned foundation layer) within the feature-first app layout.283- See `service-boundary-and-native` for injecting this `Clock` into Riverpod284 code via `clockProvider` (the same `package:clock` seam, provider-wired).285- See `error-handling-typed-results` for the sealed `Result<T, F extends Failure>`286 spine that parsing and FX return instead of throwing.287- See `i18n-rtl-l10n` for ASCII digit/separator normalization and currency288 formatting at the presentation edge (kept out of this pure core).289- See `dart3-idioms-and-coding-standards` for immutable value types, sealed types,290 and total non-throwing domain functions.291- See `persistence-drift` for storing minor units + ISO code (never a REAL) and292 mapping rows to these value objects.293- See `testing-strategy` for the clock-injected, table-driven unit tests these294 pure functions demand.295296## References297298- ISO 4217 currency exponents — https://en.wikipedia.org/wiki/ISO_4217299- `package:decimal` — https://pub.dev/packages/decimal300- `package:clock` — https://pub.dev/packages/clock301- `fake_async` — https://pub.dev/packages/fake_async302- Largest-remainder (Hamilton) method — https://en.wikipedia.org/wiki/Largest_remainder_method303- Dart records & patterns — https://dart.dev/language/records