currency_kit 0.0.1
currency_kit: ^0.0.1 copied to clipboard
Money, currency codes and exchange rates as value types — with display-only conversion, so switching the currency a user reads never changes the amount you store.
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.