Enhance Money class with precision handling and zero trimming (#3548)

* feat: enhance Money class with precision handling, scale alignment, and trailing zero trimming
This commit is contained in:
Konstantin Ullrich
2026-08-24 14:48:32 +02:00
committed by GitHub
parent 8649c055e0
commit c838012979
8 changed files with 522 additions and 109 deletions
+18 -10
View File
@@ -2,6 +2,8 @@ import 'package:cw_core/amount/money.dart';
import 'package:cw_core/currency.dart';
class ExchangeRate {
const ExchangeRate({required this.base, required this.quote});
/// The currency being priced (e.g. BTC in a BTC/USD pair).
final Currency base;
@@ -9,8 +11,6 @@ class ExchangeRate {
/// (e.g. 45000 USD in a BTC/USD pair).
final Money quote;
const ExchangeRate({required this.base, required this.quote});
/// Converts [amount] between the base and quote currencies.
///
/// Results are truncated toward zero.
@@ -18,23 +18,31 @@ class ExchangeRate {
/// Throws an [ArgumentError] if [amount]'s currency is not part of
/// this pair.
Money convert(Money amount) {
if (base == quote.currency && base == amount.currency) return amount;
final scale = BigInt.from(10).pow(base.decimals);
if (base == quote.currency && base == amount.currency) {
return amount;
}
if (amount.currency == base) {
if (quote.isZero) return Money.zero(quote.currency);
final scale = BigInt.from(10).pow(amount.decimals);
return Money(amount.amount * quote.amount ~/ scale, quote.currency);
return quote.isZero
? Money.zero(quote.currency)
: Money(amount.amount * quote.amount ~/ scale, quote.currency, quote.decimals);
}
if (amount.currency == quote.currency) {
if (quote.isZero) return Money.zero(base);
if (quote.isZero) {
return Money.zero(base);
}
return Money(amount.amount * scale ~/ quote.amount, base);
final numerator = amount.amount * BigInt.from(10).pow(base.decimals + quote.decimals);
final denominator = quote.amount * BigInt.from(10).pow(amount.decimals);
return Money(numerator ~/ denominator, base);
}
throw ArgumentError(
"Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair");
"Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair",
);
}
}
+109 -48
View File
@@ -1,4 +1,6 @@
import "package:cw_core/crypto_amount_format.dart";
import "dart:math";
import "package:cw_core/amount/utils.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency.dart";
import "package:cw_core/format_fixed.dart";
@@ -7,39 +9,63 @@ import "package:cw_core/parse_fixed.dart";
export "money_local.dart";
class Money implements Comparable<Money> {
const Money(this.amount, this.currency);
const Money(this.amount, this.currency, [int? overrideDecimals])
: _overrideDecimals = overrideDecimals;
factory Money.zero(Currency currency) => Money(BigInt.zero, currency);
factory Money.fromInt(int amount, Currency currency) => Money(BigInt.from(amount), currency);
/// Parse the [source] and turn it into [Money]
/// Parse the [source] and turn it into [Money] trimming trailing 0s
///
/// Throws a [FormatException] if the [source] is not a valid decimal or
/// not in canonical representation or if it is a decimal when [isBaseUnit]
factory Money.parse(source, Currency currency, {bool isBaseUnit = false}) {
final amount = isBaseUnit
? BigInt.parse(source.toString())
: parseFixed(source.toString(), currency.decimals);
factory Money.parse(
String source,
Currency currency, {
bool isBaseUnit = false,
bool strictParsing = true,
}) {
if (!isBaseUnit) {
source = trimTrailingFractionZeros(source);
}
final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
final amount = isBaseUnit ? BigInt.parse(source) : parseFixed(source, decimals);
return Money(amount, currency);
return Money(amount, currency, decimals);
}
/// Parse the [source] and turn it into [Money] if possible
/// Parse the [source] and turn it into [Money] if possible trimming trailing 0s
///
/// As [parse] except that this method returns `null` if the input is not
/// valid or if it is a decimal when [isBaseUnit]
static Money? tryParse(source, Currency currency, {bool isBaseUnit = false}) {
final amount = isBaseUnit
? BigInt.tryParse(source.toString())
: tryParseFixed(source.toString(), currency.decimals);
static Money? tryParse(
String source,
Currency currency, {
bool isBaseUnit = false,
bool strictParsing = true,
}) {
try {
if (!isBaseUnit) {
source = trimTrailingFractionZeros(source);
}
final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency);
final amount = isBaseUnit ? BigInt.tryParse(source) : tryParseFixed(source, decimals);
return amount != null ? Money(amount, currency) : null;
return amount != null ? Money(amount, currency, decimals) : null;
} catch (_) {
return null;
}
}
final BigInt amount;
final Currency currency;
final int? _overrideDecimals;
/// Returns the amount of decimals of [currency]
int get decimals => _overrideDecimals ?? currency.decimals;
/// Returns the sign of this [BigInt] amount.
/// Returns 0 for zero, -1 for values less than zero and +1 for values
/// greater than zero.
@@ -59,15 +85,25 @@ class Money implements Comparable<Money> {
int compareTo(Money other) {
_assertSameCurrency(other);
return amount.compareTo(other.amount);
final aligned = _align(other);
return aligned.a.compareTo(aligned.b);
}
/// Returns `true` if [other] is the same amount of money in
/// the same currency.
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Money && other.currency == currency && other.amount == amount);
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! Money || other.currency != currency) {
return false;
}
final aligned = _align(other);
return aligned.a == aligned.b;
}
/// Returns `true` when this money is less than [other].
///
@@ -76,7 +112,8 @@ class Money implements Comparable<Money> {
bool operator <(Money other) {
_assertSameCurrency(other, "Cannot compare money in different currencies.");
return amount < other.amount;
final aligned = _align(other);
return aligned.a < aligned.b;
}
/// Returns `true` when this money is less than or equal to [other].
@@ -86,7 +123,8 @@ class Money implements Comparable<Money> {
bool operator <=(Money other) {
_assertSameCurrency(other, "Cannot compare money in different currencies.");
return amount <= other.amount;
final aligned = _align(other);
return aligned.a <= aligned.b;
}
/// Returns `true` when this money is greater than [other].
@@ -96,7 +134,8 @@ class Money implements Comparable<Money> {
bool operator >(Money other) {
_assertSameCurrency(other, "Cannot compare money in different currencies.");
return amount > other.amount;
final aligned = _align(other);
return aligned.a > aligned.b;
}
/// Returns `true` when this money is greater than or equal to [other].
@@ -106,7 +145,8 @@ class Money implements Comparable<Money> {
bool operator >=(Money other) {
_assertSameCurrency(other, "Cannot compare money in different currencies.");
return amount >= other.amount;
final aligned = _align(other);
return aligned.a >= aligned.b;
}
/// Adds the amount of [other] to this amount.
@@ -116,11 +156,12 @@ class Money implements Comparable<Money> {
Money operator +(Money other) {
_assertSameCurrency(other);
return _withAmount(amount + other.amount);
final aligned = _align(other);
return copyWith(amount: aligned.a + aligned.b, decimals: aligned.decimals);
}
/// unary minus operator.
Money operator -() => _withAmount(-amount);
Money operator -() => copyWith(amount: -amount);
/// Subtracts the amount of [other] from this amount.
///
@@ -129,13 +170,14 @@ class Money implements Comparable<Money> {
Money operator -(Money other) {
_assertSameCurrency(other);
return _withAmount(amount - other.amount);
final aligned = _align(other);
return copyWith(amount: aligned.a - aligned.b, decimals: aligned.decimals);
}
/// Returns [Money] multiplied by [other].
///
/// The result is again [Money].
Money operator *(BigInt other) => _withAmount(amount * other);
Money operator *(BigInt other) => copyWith(amount: amount * other);
/// Returns [Money] divided by [other].
///
@@ -156,7 +198,7 @@ class Money implements Comparable<Money> {
final twiceR = r << 1;
final mag = (twiceR >= B) ? q + BigInt.one : q;
return _withAmount(neg ? -mag : mag);
return copyWith(amount: neg ? -mag : mag);
}
/// Creates a copy of this [Money] object with optional new values
@@ -164,46 +206,63 @@ class Money implements Comparable<Money> {
///
/// If just [currency] is provided and the decimals missmatch
/// the amount will be transformed to keep its canonical representation
Money copyWith({BigInt? amount, Currency? currency}) {
if (currency != null && amount == null && currency.decimals != this.currency.decimals) {
Money copyWith({BigInt? amount, Currency? currency, int? decimals}) {
if (currency != null && amount == null && decimals == null &&
currency.decimals != this.decimals) {
return Money(
_transformAmount(this.amount, this.currency.decimals, currency.decimals),
_transformAmount(this.amount, this.decimals, currency.decimals),
currency,
currency.decimals,
);
}
return Money(amount ?? this.amount, currency ?? this.currency);
return Money(amount ?? this.amount, currency ?? this.currency, decimals ?? this.decimals);
}
/// Creates new instance with the same currency and given [amount].
Money _withAmount(BigInt amount) => Money(amount, currency);
void _assertSameCurrency(Money other, [String? message]) {
if (currency != other.currency) {
throw ArgumentError(message ?? "Cannot operate with money values in different currencies.");
}
}
BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
if (sourceDecimals == targetDecimals) {
return source;
static BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) {
final diff = targetDecimals - sourceDecimals;
return diff == 0
? source
: diff > 0
? source * BigInt.from(10).pow(diff)
: source ~/ BigInt.from(10).pow(-diff);
}
({BigInt a, BigInt b, int decimals}) _align(Money other) {
final decimals = max(this.decimals, other.decimals);
return (
a: _transformAmount(amount, this.decimals, decimals),
b: _transformAmount(other.amount, other.decimals, decimals),
decimals: decimals,
);
}
static int _getActualDecimals(String value, Currency currency) {
final comps = value.split(".");
if (comps.length > 2) {
throw FormatException("Money._getActualDecimals: too many decimal points, value, $value");
}
if (sourceDecimals > targetDecimals) {
return parseFixed(
formatFixed(source, sourceDecimals).withMaxDecimals(targetDecimals),
targetDecimals,
);
} else {
return parseFixed(formatFixed(source, sourceDecimals), targetDecimals);
}
return max(comps.length == 2 ? comps[1].length : 0, currency.decimals);
}
@override
int get hashCode => amount.hashCode ^ currency.hashCode;
int get hashCode {
final value = formatFixed(amount, this.decimals, trimZeros: true);
final decimals = _getActualDecimals(value, currency);
return Object.hash(value, decimals, currency);
}
@override
String toString() => formatFixed(amount, currency.decimals);
String toString() => formatFixed(amount, decimals);
String toStringWithSymbol({
int? fractionalDigits,
@@ -227,7 +286,9 @@ class Money implements Comparable<Money> {
bool useBaseUnit = false,
}) =>
formatFixed(
amount,
decimals != currency.decimals
? _transformAmount(amount, decimals, currency.decimals)
: amount,
useBaseUnit ? 0 : currency.decimals,
fractionalDigits: fractionalDigits,
trimZeros: trimZeros,
+9 -6
View File
@@ -1,13 +1,16 @@
import "dart:math";
import "package:cw_core/amount/money.dart";
import "package:cw_core/amount/utils.dart";
import "package:cw_core/currency.dart";
/// Turn a double representation of a currency amount to a proper Money representation
/// truncating currencies with more than 20 decimals because double can not handle more
extension ToMoney on double {
/// Turn a double representation of a currency amount to a proper Money representation
/// truncating currencies with more than 20 decimals because double can not handle more
Money? tryToMoney(Currency currency) =>
Money.tryParse(toStringAsFixed(min(currency.decimals, 20)), currency);
Money? tryToMoney(Currency currency) => Money.tryParse(_toSafeString(currency), currency);
Money toMoney(Currency currency) =>
Money.parse(toStringAsFixed(min(currency.decimals, 20)), currency);
Money toMoney(Currency currency) => Money.parse(_toSafeString(currency), currency);
String _toSafeString(Currency currency) =>
trimTrailingFractionZeros(toStringAsFixed(min(currency.decimals, 20)));
}
+13
View File
@@ -0,0 +1,13 @@
String trimTrailingFractionZeros(String value) {
if (!value.contains('.')) {
return value;
}
var end = value.length;
while (end > 0 && value[end - 1] == "0") {
end--;
}
final trimmed = end == value.length ? value : value.substring(0, end);
return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
}
+2 -2
View File
@@ -55,7 +55,7 @@ BigInt parseFixed(String value, int decimals) {
}
final whole = comps.isNotEmpty ? comps[0] : "0";
final fraction = (comps.length == 2 ? comps[1] : "0").padRight(decimals, "0");
final fraction = (comps.length == 2 ? comps[1] : "").padRight(decimals, "0");
if (fraction.length > multiplier.length - 1) {
throw FormatException(
@@ -64,7 +64,7 @@ BigInt parseFixed(String value, int decimals) {
}
final wholeValue = BigInt.parse(whole);
final fractionValue = BigInt.parse(fraction);
final fractionValue = fraction.isEmpty ? BigInt.zero : BigInt.parse(fraction);
final multiplierValue = BigInt.parse(multiplier);
var wei = (wholeValue * multiplierValue) + fractionValue;
+209 -41
View File
@@ -1,9 +1,10 @@
import 'package:cw_core/amount/exchange_rate.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() {
group('ExchangeRate', () {
final rate = ExchangeRate(
@@ -99,49 +100,216 @@ void main() {
test('handle quote being 0 correctly', () {
final zeroRate = ExchangeRate(
base: CryptoCurrency.btc,
quote: Money(BigInt.zero, EUR),
quote: Money(BigInt.zero, EUR, EUR.decimals),
);
expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR));
expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc));
});
test('handle super low quote correctly', () {
final zeroRate = ExchangeRate(
base: CryptoCurrency.shib,
quote: Money.parse("0.00000444", EUR, strictParsing: false),
);
expect(
zeroRate.convert(Money.parse("1", CryptoCurrency.shib)),
Money.parse("0.00000444", EUR, strictParsing: false),
);
expect(
zeroRate.convert(Money.fromInt(10000, EUR)),
Money.parse("22522522.522522522522522522", CryptoCurrency.shib),
);
expect(
zeroRate.convert(Money.parse("200", CryptoCurrency.shib)),
Money.parse("0.000888", EUR, strictParsing: false),
);
});
group('Precision and scale', () {
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
test('convert is insensitive to the input scale (forward)', () {
// Same value, three different internal scales.
final coarse = Money(BigInt.one, CryptoCurrency.btc, 0);
final normal = Money.parse("1", CryptoCurrency.btc);
final fine = Money.parse("1.000000000000000000", CryptoCurrency.btc, strictParsing: false);
expect(rate.convert(coarse), Money.parse("60000.00", EUR));
expect(rate.convert(normal), Money.parse("60000.00", EUR));
expect(rate.convert(fine), Money.parse("60000.00", EUR));
});
test('convert is insensitive to the input scale (reverse)', () {
expect(
rate.convert(Money(BigInt.one, EUR, 0)), // €1 at scale 0
rate.convert(Money.parse("1", EUR)), // €1 at scale 2
);
});
test('sub-unit precision in the input does not leak into the result', () {
// ad = 18 here, well past BTC's 8. The divisor must cancel the *amount's*
// scale, not the base currency's.
final overPrecise =
Money.parse("1.000000000000000001", CryptoCurrency.btc, strictParsing: false);
expect(rate.convert(overPrecise), Money.parse("60000.00", EUR));
});
test('the result carries the quote scale, not the currency default', () {
final preciseRate = ExchangeRate(
base: CryptoCurrency.btc,
quote: Money.parse("60000.123456", EUR, strictParsing: false),
);
final result = preciseRate.convert(Money.parse("1", CryptoCurrency.btc));
expect(result.decimals, 6);
expect(result, Money.parse("60000.123456", EUR, strictParsing: false));
});
test('a quote scale finer than the currency survives the round trip', () {
final preciseRate = ExchangeRate(
base: CryptoCurrency.btc,
quote: Money.parse("60000.50", EUR, strictParsing: false),
);
expect(
preciseRate.convert(preciseRate.convert(Money.parse("1", CryptoCurrency.btc))),
Money.parse("1", CryptoCurrency.btc),
);
});
});
group('Truncation', () {
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
test('truncates rather than rounding, even past the halfway point', () {
// 9 sats = 0.54 cents. Rounding would give 1.
expect(rate.convert(Money(BigInt.from(9), CryptoCurrency.btc)), Money.zero(EUR));
// 1 cent = 16.66 sats. Rounding would give 17.
expect(rate.convert(Money(BigInt.one, EUR)), Money.fromInt(16, CryptoCurrency.btc));
});
test('truncates toward zero for negatives, not downward', () {
// -0.54 cents must be 0, not -1.
expect(rate.convert(Money(BigInt.from(-9), CryptoCurrency.btc)), Money.zero(EUR));
expect(rate.convert(Money(BigInt.from(-1), EUR)), Money.fromInt(-16, CryptoCurrency.btc));
});
test('truncation is symmetric around zero', () {
final positive = rate.convert(Money(BigInt.from(7), EUR));
final negative = rate.convert(Money(BigInt.from(-7), EUR));
expect(negative, -positive);
});
test('round trip loses less than one quote base unit', () {
final original = Money.parse("0.12345678", CryptoCurrency.btc);
final back = rate.convert(rate.convert(original));
final oneQuoteUnitInBaseUnits =
BigInt.from(10).pow(CryptoCurrency.btc.decimals) ~/ rate.quote.amount;
expect(
(original - back).amount.abs(),
lessThanOrEqualTo(oneQuoteUnitInBaseUnits + BigInt.one),
);
});
test('round trip never inflates the amount', () {
// Both truncations round toward zero, so the result can only shrink.
for (final source in ["0.12345678", "1", "0.5", "21000000"]) {
final original = Money.parse(source, CryptoCurrency.btc);
expect(
rate.convert(rate.convert(original)) <= original,
isTrue,
reason: "round trip grew $source",
);
}
});
test('a value below one base unit of the quote collapses to zero', () {
final back = rate.convert(rate.convert(Money(BigInt.one, CryptoCurrency.btc)));
expect(back.isZero, isTrue);
});
});
group('Extreme decimal spreads', () {
test('30 decimals against 0 decimals: NANO/JPY', () {
final rate = ExchangeRate(base: CryptoCurrency.nano, quote: Money.parse("150", JPY));
expect(rate.convert(Money.parse("1", CryptoCurrency.nano)), Money.fromInt(150, JPY));
expect(rate.convert(Money.fromInt(150, JPY)), Money.parse("1", CryptoCurrency.nano));
expect(rate.convert(Money.fromInt(1, JPY)).decimals, 30);
});
test('0 decimals against 18 decimals: JPY/SHIB', () {
final rate = ExchangeRate(base: JPY, quote: Money.parse("2500", CryptoCurrency.shib));
expect(rate.convert(Money.fromInt(2, JPY)), Money.parse("5000", CryptoCurrency.shib));
expect(rate.convert(Money.parse("5000", CryptoCurrency.shib)), Money.fromInt(2, JPY));
});
test('no overflow at the extremes', () {
final rate = ExchangeRate(
base: CryptoCurrency.shib,
quote: Money.parse("0.00000444", EUR, strictParsing: false),
);
final totalSupply = Money.parse("589000000000000", CryptoCurrency.shib);
expect(rate.convert(totalSupply).isNegative, isFalse);
expect(rate.convert(rate.convert(totalSupply)) <= totalSupply, isTrue);
});
});
group('Degenerate pairs', () {
test('identity pair returns the amount untouched', () {
final identity = ExchangeRate(base: EUR, quote: Money.parse("1", EUR));
expect(identity.convert(Money.parse("42.42", EUR)), Money.parse("42.42", EUR));
expect(identity.convert(Money.zero(EUR)), Money.zero(EUR));
});
test('a same-currency pair silently ignores its own rate', () {
final bogus = ExchangeRate(base: EUR, quote: Money.parse("2", EUR));
expect(bogus.convert(Money.parse("10", EUR)), Money.parse("10", EUR));
});
test('a rate of exactly one is a no-op in both directions', () {
final rate = ExchangeRate(base: JPY, quote: Money.fromInt(100, EUR));
expect(rate.convert(Money.fromInt(7, JPY)), Money.fromInt(700, EUR));
expect(rate.convert(Money.fromInt(700, EUR)), Money.fromInt(7, JPY));
});
test('negative quotes propagate their sign', () {
final inverted = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("-60000", EUR));
expect(
inverted.convert(Money.parse("1", CryptoCurrency.btc)),
Money.parse("-60000.00", EUR),
);
expect(
inverted.convert(Money.parse("-60000.00", EUR)),
Money.parse("1", CryptoCurrency.btc),
);
});
test('zero amount with a zero quote', () {
final zeroRate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.zero(EUR));
expect(zeroRate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR));
expect(zeroRate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc));
});
test('rejects a foreign currency in both slots', () {
final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR));
expect(() => rate.convert(Money.parse("1", CryptoCurrency.xmr)), throwsArgumentError);
expect(() => rate.convert(Money.parse("1", USD)), throwsArgumentError);
});
});
});
}
class FiatCurrency implements Currency {
const FiatCurrency({
required this.symbol,
required this.countryCode,
required this.fullName,
this.decimals = 2,
});
final String countryCode;
@override
final String fullName;
@override
final int decimals;
@override
final String symbol;
@override
String? get iconPath => throw UnimplementedError();
@override
String get name => throw UnimplementedError();
@override
String? get tag => throw UnimplementedError();
@override
Money parseAmount(String value) => Money.parse(value, this);
@override
Money? tryParseAmount(String value) => Money.tryParse(value, this);
}
const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro");
const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar");
const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0);
+121 -2
View File
@@ -2,6 +2,8 @@ import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:flutter_test/flutter_test.dart";
import "utils.dart";
void main() {
group("Money", () {
test("parse", () {
@@ -27,7 +29,7 @@ void main() {
expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException);
// To many decimals
expect(() => Money.parse("-1.000000000000000", CryptoCurrency.btc), throwsFormatException);
expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException);
money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true);
expect(money.amount, BigInt.from(1));
@@ -66,7 +68,7 @@ void main() {
expect(money?.amount, isNull);
// To many decimals
money = Money.tryParse("-1.000000000000000", CryptoCurrency.btc);
money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc);
expect(money?.amount, isNull);
money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true);
@@ -373,5 +375,122 @@ void main() {
});
});
});
group("different scales", () {
final coarseOne = Money(BigInt.one, CryptoCurrency.btc, 0);
final fineOne = Money(BigInt.from(100000000), CryptoCurrency.btc, 8);
test("== across scales", () {
expect(coarseOne, equals(fineOne));
expect(coarseOne, isNot(equals(Money(BigInt.two, CryptoCurrency.btc, 0))));
});
test("equal values across scales hash equally", () {
expect(coarseOne.hashCode, equals(fineOne.hashCode));
expect(
Money(BigInt.from(-11), CryptoCurrency.btc, 1).hashCode,
equals(Money(BigInt.from(-110000000), CryptoCurrency.btc, 8).hashCode),
);
});
test("zero hashes equally at every scale", () {
final hashes = [0, 1, 8, 18, 30]
.map((s) => Money(BigInt.zero, CryptoCurrency.btc, s).hashCode)
.toSet();
expect(hashes, hasLength(1));
});
test("whole units are not confused with their digits", () {
// (100, scale 0) is one hundred, not one: stripping must stop at 0.
final oneHundred = Money(BigInt.from(100), CryptoCurrency.btc, 0);
expect(oneHundred, isNot(equals(coarseOne)));
expect(oneHundred.hashCode, isNot(equals(coarseOne.hashCode)));
});
test("Set deduplicates across scales", () {
expect(
{
Money(BigInt.from(11), CryptoCurrency.btc, 1),
Money(BigInt.from(110000000), CryptoCurrency.btc, 8),
Money(BigInt.from(1100), CryptoCurrency.btc, 3),
},
hasLength(1),
);
});
test("+ aligns operands", () {
expect(coarseOne + fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
expect(coarseOne + fineOne, equals(fineOne + coarseOne));
});
test("- aligns operands", () {
// Regression: this used to subtract the raw amounts, i.e. 1 - 100000000.
expect((coarseOne - fineOne).isZero, isTrue);
expect((fineOne - coarseOne).isZero, isTrue);
final threeCoarse = Money(BigInt.from(3), CryptoCurrency.btc, 0);
expect(threeCoarse - fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0)));
expect(threeCoarse - fineOne, equals(-(fineOne - threeCoarse)));
});
test("the result keeps the finer scale", () {
expect((coarseOne + fineOne).decimals, greaterThanOrEqualTo(fineOne.decimals));
});
test("sub-unit precision survives a coarse operand", () {
final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc, 8);
expect(
coarseOne + oneSatoshi,
equals(Money(BigInt.from(100000001), CryptoCurrency.btc, 8)),
);
});
test("comparison operators align", () {
expect(coarseOne < fineOne, isFalse);
expect(coarseOne <= fineOne, isTrue);
expect(coarseOne >= fineOne, isTrue);
expect(coarseOne > fineOne, isFalse);
expect(Money(BigInt.two, CryptoCurrency.btc, 0) > fineOne, isTrue);
});
test("compareTo aligns and agrees with ==", () {
expect(coarseOne.compareTo(fineOne), isZero);
expect(coarseOne.compareTo(fineOne) == 0, equals(coarseOne == fineOne));
expect(Money(BigInt.two, CryptoCurrency.btc, 0).compareTo(fineOne), isPositive);
expect(Money(BigInt.zero, CryptoCurrency.btc, 0).compareTo(fineOne), isNegative);
});
test("toStringWithPrecision", () {
expect(
Money.parse("60000.123456", USD, strictParsing: false).toStringWithPrecision(),
"60000.12",
);
expect(
Money.parse("1.23456789", USD, strictParsing: false).toStringWithPrecision(),
"1.23",
);
expect(Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(), "1");
expect(
Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(useBaseUnit: true),
"100000000",
);
});
test("toString", () {
expect(
Money.parse("60000.123456", CryptoCurrency.btc, strictParsing: false).toString(),
"60000.123456",
);
expect(
Money.parse("1.23456789", CryptoCurrency.btc, strictParsing: false).toString(),
"1.23456789",
);
expect(Money(BigInt.one, CryptoCurrency.btc, 0).toString(), "1");
});
});
});
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/currency.dart';
class FiatCurrency implements Currency {
const FiatCurrency({
required this.symbol,
required this.countryCode,
required this.fullName,
this.decimals = 2,
});
final String countryCode;
@override
final String fullName;
@override
final int decimals;
@override
final String symbol;
@override
String? get iconPath => throw UnimplementedError();
@override
String get name => throw UnimplementedError();
@override
String? get tag => throw UnimplementedError();
@override
Money parseAmount(String value) => Money.parse(value, this);
@override
Money? tryParseAmount(String value) => Money.tryParse(value, this);
}
const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro");
const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar");
const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0);