Loading...
Loading...
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.
npx skill4agent add zakariaf/flutter-skills value-objects-money-and-unitsflutter/*intldart:iolib/core/project-structure-and-packagesutils/common/shared/references/allocate-and-splitting.mdallocate()references/canonical-storage.mddecimalreferences/domain-model.mdscripts/check-money-violations.shscripts/verify-core.shintCurrencydoublenumREAL0.01double* 100/ 100100currency.minorPerMajorCurrency.tryParseFailureMoneyMoneyallocate(amount, weights)allocate()allocate()doubleallocateallocate()idfinal idintDateTime.from<DisplayUnit>to<DisplayUnit>()doubleint.parsedouble.parseDecimal.parsei18n-rtl-l10ndecimalpackage:decimalRoundingModepackage:clockClockDateTime.now()ClockServiceClockClockclockProviderservice-boundary-and-nativefake_asyncdecimalclockint| Quantity | Canonical storage | Type | Never store |
|---|---|---|---|
| Money | integer minor units + | | double, formatted string |
| Distance | whole metres | | km, miles |
| Volume | whole millilitres | | litres, gallons |
| Duration | whole minutes (or | | hours as double |
| Timestamp | UTC ISO-8601 instant | | 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}');
int compareTo(Money o) {
assert(currency == o.currency, 'compare across currencies is a bug');
return minorUnits.compareTo(o.minorUnits);
}
bool operator ==(Object o) =>
o is Money && o.minorUnits == minorUnits && o.currency == currency;
int get hashCode => Object.hash(minorUnits, currency);
}.from<Unit>to<Unit>()references/canonical-storage.mdexamples/money.dartallocate()/// 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;
}allocate(1001,[1,1,1]) == [334,334,333]allocate(660,[1584,4033,1933]) == [138,353,169]allocate(1510,[1584,4033,1933]) == [317,807,386]allocate()allocate(taxMinor, subtotals)allocate(tipMinor, subtotals)references/allocate-and-splitting.mdexamples/allocate.dartdecimal/// 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);
}references/canonical-storage.mdClockfinal 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));double amountnum price0.01intamount * 100cents / 100currency.minorPerMajorMoneyallocate()allocate()totalDateTime.now()ClockServiceSystemClockFakeClockpackage:clockClockDecimal(someDouble)int.parseDecimalStringintCurrencydoublenumcurrency.minorPerMajor100allocate()allocate()idintClockDateTime.now()decimalclockscripts/check-money-violations.shscripts/verify-core.shproject-structure-and-packageslib/core/service-boundary-and-nativeClockclockProviderpackage:clockerror-handling-typed-resultsResult<T, F extends Failure>i18n-rtl-l10ndart3-idioms-and-coding-standardspersistence-drifttesting-strategypackage:decimalpackage:clockfake_async