currency_kit
Money, currency codes and exchange rates as value types — built around one rule:
An amount you store is always in one canonical currency. A second currency is a view of it. Selecting a currency changes what a user reads, never what you keep.
Get that rule wrong and a user changes a dropdown and your data moves. This package makes the right thing the easy thing, and the wrong thing hard to express.
The problem
The usual approach converts state when the user switches currency, and converts back when saving. It looks symmetrical. It isn't — because the value is rounded to the display currency's precision while it sits there:
AED 0.03 → shown as USD 0.01 → saved back as AED 0.04
Across the first 2,000 dirham at a rate of 3.69, more than 70% of amounts do not survive a single currency toggle. The package's test suite pins that number, because it is the reason the package exists.
The rule
A rate is applied at exactly two boundaries, and nowhere else:
| Boundary | Direction | Method |
|---|---|---|
| The user typed an amount in the currency they are reading | display → canonical | rate.toQuote(typed) |
| An amount is stored canonically and must be shown | canonical → display | rate.toBase(stored) |
Between them, everything stays canonical. There is deliberately no API for re-basing stored state.
Usage
import 'package:currency_kit/currency_kit.dart';
// The books are kept in AED; a supplier quotes in USD.
final rate = ExchangeRate(
base: CurrencyCode.usd,
quote: CurrencyCode.aed,
rate: 3.69,
);
print(rate); // 1 USD = 3.69 AED
// The user is reading USD and types 10. Convert once, then store.
final stored = rate.toQuote(const Money(10, CurrencyCode.usd)).rounded();
print(stored.format()); // AED 36.90
// They switch the picker to AED, then back to USD. Nothing is recomputed —
// the same stored amount is simply rendered differently.
print(stored.format()); // AED 36.90
print(rate.toBase(stored).rounded().format()); // USD 10.00
// The stored amount never moved.
Money
Money carries its currency, so "which currency is this number in?" is a type
rather than a comment. Mixing currencies throws instead of quietly producing a
meaningless number:
const aed = Money(36.90, CurrencyCode.aed);
const usd = Money(10, CurrencyCode.usd);
aed + usd; // throws CurrencyMismatchError
aed.copyWith(currency: CurrencyCode.usd); // relabels — does NOT convert
Precision follows the currency
CurrencyCode carries its ISO-4217 minor-unit count, so JPY gets 0 decimals and
KWD gets 3 without anyone remembering to ask:
const Money(1234.5, CurrencyCode.jpy).format(); // JPY 1,235
const Money(1234.5, CurrencyCode.kwd).format(); // KWD 1,234.500
Apps that need one precision everywhere (to match a back office, say) override it in the registry rather than at 60 call sites:
const formatter = MoneyFormatter(
registry: CustomCurrencyRegistry(fractionDigitsForAll: 2),
);
Parsing never guesses
An unknown code is an error, not a silent fallback — a mislabelled amount is worse than a failed parse:
CurrencyCode.parse('XYZ'); // throws UnknownCurrencyCode
CurrencyCode.tryParse('XYZ'); // null
CurrencyCode.custom('PTS', fractionDigits: 0); // for codes we don't ship
MoneyParser reads back anything MoneyFormatter writes, plus what people
actually type — affixes, grouping separators, Arabic-Indic digits, and a minus
sign on either side of the currency code.
Rounding
Money rounds the decimal a person typed, half away from zero:
const Money(412.565, CurrencyCode.aed).format(); // AED 412.57
That is not what rounding the double gives you. 412.565 is stored as
412.56499…, so toStringAsFixed — and intl, and most naive
implementations — round it down to 412.56. The difference shows up on
about 5% of typed amounts, which is far too often to leave to chance.
roundToFractionDigits gets the humane answer without an epsilon fudge, by
rounding the shortest decimal string that round-trips to the same double. If
you need to match a system that rounds the binary value instead:
roundToFractionDigits(412.565, 2, mode: RoundingMode.halfUpBinary); // 412.56
money.rounded(2, RoundingMode.halfUpBinary);
const MoneyFormat(rounding: RoundingMode.halfUpBinary);
Amounts are double. That is a documented compromise matching the JSON and
model types this package was extracted to serve; Money is the seam that makes
a later move to integer minor units possible without touching call sites.
Not in this package
- No network. Rates come from wherever your app gets them.
- No widgets. This is pure Dart.
- No re-basing API. By design — see the rule.
Libraries
- currency_kit
- Money, currency codes and exchange rates as value types.