value-objects-money-and-units
Skill32 starsChanged 4 months agoMITView on GitHub
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.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- zakariaf/CatchLawword for word, first seen Aug 1, 2026
Other versions1 project uses a version of this.
Word for word · 1
- zakariaf/CatchLaw★ 0100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository40 other files it gives its agents.
- AGENTS.mdAGENTS.md
- accessibility-as-codeSkill
- adaptive-layoutSkill
- ads-and-iap-monetizationSkill
- app-startup-and-bootstrapSkill
- async-safetySkill
- ci-pipeline-and-gatesSkill
- codegen-and-toolchainSkill
- custom-canvas-and-gesturesSkill
- dart3-idioms-and-coding-standardsSkill
- dartdoc-conventionsSkill
- data-export-and-restoreSkill
- dependency-hygieneSkill
- design-review-workflowSkill
- design-system-structureSkill
- error-handling-typed-resultsSkill
- flutter-architectureSkill
- flutter-conventions-indexSkill
- flutter-performanceSkill
- forms-and-inputSkill
- i18n-rtl-l10nSkill
- lint-and-style-configSkill
- local-notifications-schedulerSkill
- motion-and-hapticsSkill
- naming-conventionsSkill
- navigation-and-routingSkill
- persistence-driftSkill
- project-structure-and-packagesSkill
- release-and-store-shippingSkill
- run-codegenSkill
- run-goldens-rebaselineSkill
- run-migrationSkill
- scaffold-feature-moduleSkill
- seeded-determinism-and-golden-vectorsSkill
- service-boundary-and-nativeSkill
- state-management-riverpodSkill
- testing-strategySkill
- ui-states-and-feedbackSkill
- widget-compositionSkill
- widget-golden-and-a11y-testingSkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 32
Read the reference for the task at hand:
- Very specific: line 43
Run scripts/check-money-violations.sh and scripts/verify-core.sh before a PR.
- Very specific: line 51
NEVER hardcode * 100, / 100, or "2 decimals".
- Very specific: line 70
Never sum independently-rounded parts to get a total: Always allocate() a known integer total and let the parts absorb the residual.
- Very specific: line 85
Use package:decimal for exact division/parsing, apply an explicit RoundingMode (default half-even/banker's) once at the parse or …
- Distinctive: line 168
Every time money is split, it goes through this integer largest-remainder (Hamilton) primitive.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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-remainderallocate()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
intminor units + aCurrency— NEVERdouble/num/REAL. Binary floats cannot represent0.01; drift silently corrupts totals the user can never re-derive. No money API accepts or returnsdouble. - 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 hardcoded100is a 100× error for a 0-exponent currency and a 10× error for a 3-exponent one. Route throughcurrency.minorPerMajor. - Unknown currency code is a typed failure, never a silent default-to-2. The
exponent table lists only shipped currencies;
Currency.tryParsereturns null and the caller emits aFailure. - One currency per aggregate; cross-currency arithmetic is forbidden. Adding
two
Moneyof different currencies is a category error — throw (programmer error) or convert through the FX layer first. Keep currency a fact of the enclosing aggregate soMoneyarithmetic 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 feedingallocate(). A naivedoublepercent is a classic off-by-a-cent bug thatallocatecoverage 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-
idlinks, not embedded copies. Give every entity an explicitfinal 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 (
intmetres / millilitres / minutes); time is a UTCDateTime..from<DisplayUnit>factories round into canonical;to<DisplayUnit>()getters return adoubleused only at the presentation edge. - Normalize digits/separators to ASCII BEFORE input reaches this core. Never
call
int.parse/double.parse/Decimal.parseon raw localized input — it throws on Eastern-Arabic numerals. Fold upstream (seei18n-rtl-l10n). - Parse with
decimal; round ONCE with an explicit mode at the boundary. Usepackage:decimalfor exact division/parsing, apply an explicitRoundingMode(default half-even/banker's) once at the parse or final-total boundary — never on intermediate sums (accumulates bias). - Inject
package:clock'sClock; NEVER callDateTime.now(), never roll a bespokeClockService. Every time-reading class in this pure core takes aClockconstructor arg; Riverpod/feature code injects the sameClockthrough aclockProvider(seeservice-boundary-and-native) so the two vocabularies compose. Fixed clocks /fake_asyncthen make time-dependent logic deterministic in tests. - The core stays Flutter-free and IO-free. Deps are only
decimalandclock. 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 pricefields. Cannot represent0.01; corrupts totals irreversibly. Useintminor units.amount * 100/cents / 100. Wrong for every non-2-exponent currency. Route throughcurrency.minorPerMajor.- Defaulting an unknown currency to 2 decimals. Silently mis-scales. Return a typed failure.
- Adding
Moneyacross 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
totalon the entity. Derive it. DateTime.now(), or a hand-rolledClockService/SystemClock/FakeClock, inside the pure core. Untestable / non-composable time. Injectpackage:clock'sClock.Decimal(someDouble)orint.parseon raw localized input. Binary error / throws on Eastern digits. ParseDecimalfrom aString; normalize first.- Rounding intermediate sums. Accumulates bias. Round once at the boundary.
Definition of done
- Every money field is
intminor units + aCurrency; nodouble/num. - All scaling goes through
currency.minorPerMajor; no literal100. - Unknown currency returns a typed failure, never a defaulted parse.
- No cross-currency arithmetic; currency lives at the aggregate level.
- Every money split calls
allocate(); conservation asserted and tested with the verified vectors. - Percentages round to integer minor units once, before
allocate(). - No stored totals; totals derive on read.
- Entities carry an explicit stable
id; relationships are id links. - Physical quantities stored as SI
ints; converted only at the edge. - Time-reading classes take an injected
Clock; noDateTime.now()in core. - Core imports only
decimalandclock; no Flutter/intl/dart:io. scripts/check-money-violations.shandscripts/verify-core.shpass.
Related skills
- See
project-structure-and-packagesfor where this pure core lives (lib/core/, the sanctioned foundation layer) within the feature-first app layout. - See
service-boundary-and-nativefor injecting thisClockinto Riverpod code viaclockProvider(the samepackage:clockseam, provider-wired). - See
error-handling-typed-resultsfor the sealedResult<T, F extends Failure>spine that parsing and FX return instead of throwing. - See
i18n-rtl-l10nfor ASCII digit/separator normalization and currency formatting at the presentation edge (kept out of this pure core). - See
dart3-idioms-and-coding-standardsfor immutable value types, sealed types, and total non-throwing domain functions. - See
persistence-driftfor storing minor units + ISO code (never a REAL) and mapping rows to these value objects. - See
testing-strategyfor the clock-injected, table-driven unit tests these pure functions demand.
References
- ISO 4217 currency exponents — https://en.wikipedia.org/wiki/ISO_4217
package:decimal— https://pub.dev/packages/decimalpackage:clock— https://pub.dev/packages/clockfake_async— https://pub.dev/packages/fake_async- Largest-remainder (Hamilton) method — https://en.wikipedia.org/wiki/Largest_remainder_method
- Dart records & patterns — https://dart.dev/language/records
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

