value-objects-money-and-units

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Value objects: money & units

Value Object:金额与单位

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-remainder
    allocate()
    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.
每个数量只应存储一次,并统一采用一种规范形态,仅在展示或 导出时进行转换。Money 由整数最小货币单位和 Currency 组成;物理量使用完整的 SI 基本单位;时间使用 UTC。正因如此,用户无论切换任何展示偏好(货币符号、单位制、locale),都不会破坏任何一行存储的数据,也能让“各部分之和 == 整体”成为结构性保证,而不是仅靠期望。
此核心是纯 Dart——不包含
flutter/*
、
intl
、
dart:io
或任何 plugin。格式化和数字规范化在上游或下游完成,而不在此核心中完成。这些 Value Object 位于
lib/core/
,即经过认可的纯基础层(参见
project-structure-and-packages
),绝不能塞进
utils/
/
common/
/
shared/
这种大杂烩目录。
阅读与当前任务相关的参考资料:
  • references/allocate-and-splitting.md
    ——largest-remainder
    allocate()
    原语、其不变量、两个舍入点陷阱、边界策略、经过验证的测试向量,以及 subtotal→tax→tip 拆分流水线。
  • references/canonical-storage.md
    ——ISO-4217 指数规则、SI 单位表、基于
    decimal
    的解析、用于键盘输入的 cents accumulator、舍入规范,以及注入 Clock 的 dated-rate/staleness engine。
  • references/domain-model.md
    ——Value Type 建模:关系采用 id 链接、derive-don't-store、每个 aggregate 仅使用一种 Currency,以及带 value equality 的不可变状态。
提交 PR 前运行
scripts/check-money-violations.sh
和
scripts/verify-core.sh
。

Non-negotiable rules

不可妥协的规则

  1. Money is
    int
    minor units + a
    Currency
    — NEVER
    double
    /
    num
    /
    REAL
    .
    Binary floats cannot represent
    0.01
    ; drift silently corrupts totals the user can never re-derive. No money API accepts or returns
    double
    .
  2. 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 hardcoded
    100
    is a 100× error for a 0-exponent currency and a 10× error for a 3-exponent one. Route through
    currency.minorPerMajor
    .
  3. Unknown currency code is a typed failure, never a silent default-to-2. The exponent table lists only shipped currencies;
    Currency.tryParse
    returns null and the caller emits a
    Failure
    .
  4. One currency per aggregate; cross-currency arithmetic is forbidden. Adding two
    Money
    of different currencies is a category error — throw (programmer error) or convert through the FX layer first. Keep currency a fact of the enclosing aggregate so
    Money
    arithmetic never has to guard it.
  5. 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.
  6. There are TWO rounding sites, not one:
    allocate()
    AND percent→minor-units.
    Round a percentage to integer minor units once before feeding
    allocate()
    . A naive
    double
    percent is a classic off-by-a-cent bug that
    allocate
    coverage will not catch.
  7. Never sum independently-rounded parts to get a total. Always
    allocate()
    a known integer total and let the parts absorb the residual.
  8. Derive totals; never store them. A denormalized stored total is the classic drift bug. Totals are computed from items + weights on read.
  9. Model relationships as stable-
    id
    links, not embedded copies.
    Give every entity an explicit
    final id
    (e.g. a UUID). Editing a price then leaves assignments intact and deleting a participant just drops them from link sets.
  10. Store canonically, convert at the edge. Physical quantities are whole SI base units (
    int
    metres / millilitres / minutes); time is a UTC
    DateTime
    .
    .from<DisplayUnit>
    factories round into canonical;
    to<DisplayUnit>()
    getters return a
    double
    used only at the presentation edge.
  11. Normalize digits/separators to ASCII BEFORE input reaches this core. Never call
    int.parse
    /
    double.parse
    /
    Decimal.parse
    on raw localized input — it throws on Eastern-Arabic numerals. Fold upstream (see
    i18n-rtl-l10n
    ).
  12. Parse with
    decimal
    ; round ONCE with an explicit mode at the boundary.
    Use
    package:decimal
    for exact division/parsing, apply an explicit
    RoundingMode
    (default half-even/banker's) once at the parse or final-total boundary — never on intermediate sums (accumulates bias).
  13. Inject
    package:clock
    's
    Clock
    ; NEVER call
    DateTime.now()
    , never roll a bespoke
    ClockService
    .
    Every time-reading class in this pure core takes a
    Clock
    constructor arg; Riverpod/feature code injects the same
    Clock
    through a
    clockProvider
    (see
    service-boundary-and-native
    ) so the two vocabularies compose. Fixed clocks /
    fake_async
    then make time-dependent logic deterministic in tests.
  14. The core stays Flutter-free and IO-free. Deps are only
    decimal
    and
    clock
    . Formatting lives in the presentation layer; storage in the data layer.
  1. Money 是
    int
    最小货币单位 +
    Currency
    ——绝不使用
    double
    /
    num
    /
    REAL
    。
    二进制浮点数无法表示
    0.01
    ;这种漂移会悄无声息地破坏总额,而且用户之后永远无法重新推导出来。金额 API 不接受或返回
    double
    。
  2. 根据 Currency 真实的 ISO-4217 指数推导每个主单位对应的最小货币单位——绝不硬编码
    * 100
    、
    / 100
    或“2 位小数”。
    JPY/VND 的指数为 0,USD/EUR 为 2,KWD/BHD/OMR 为 3。对 0 指数货币硬编码
    100
    会造成 100 倍误差,对 3 指数货币则会造成 10 倍误差。必须通过
    currency.minorPerMajor
    完成换算。
  3. 未知 Currency 代码必须产生类型化失败,绝不能静默默认为 2 位小数。 指数表只包含已发布的 Currency;
    Currency.tryParse
    返回 null,由调用方生成一个
    Failure
    。
  4. 每个 aggregate 仅使用一种 Currency;禁止跨币种运算。 对两个 Currency 不同的
    Money
    做加法属于类别错误——应抛出异常(程序员错误),或先通过 FX 层转换。Currency 始终作为外层 aggregate 的一项事实,使
    Money
    运算无需自行校验。
  5. 金额的所有除分和拆分都必须通过同一个
    allocate(amount, weights)
    原语。
    共享项目、税费分摊、小费分摊、折扣——全部走同一条舍入路径,从而只需证明并测试一次“各部分之和精确等于整体的最小货币单位”。
  6. 存在两个舍入点,而不是一个:
    allocate()
    和百分比→最小货币单位。
    将百分比舍入为整数最小货币单位且只舍入一次,然后再传给
    allocate()
    。直接使用
    double
    百分比是典型的差一分钱问题,即使
    allocate
    的测试覆盖充分也无法捕获。
  7. 绝不对分别舍入后的部分求和来得到总额。 始终对一个已知的整数总额执行
    allocate()
    ,并让各部分吸收余数。
  8. 推导总额,绝不存储总额。 反规范化存储的总额是典型的漂移问题。读取时根据项目和权重计算总额。
  9. 将关系建模为稳定
    id
    链接,而不是嵌入副本。
    为每个实体提供明确的
    final id
    (例如 UUID)。修改价格后,已有分配关系保持不变;删除参与者时,只需将其从链接集合中移除。
  10. 规范存储,在边界转换。 物理量使用完整的 SI 基本单位(
    int
    米/毫升/分钟);时间使用 UTC
    DateTime
    。
    .from<DisplayUnit>
    工厂负责舍入转入规范形态;
    to<DisplayUnit>()
    getter 返回仅供展示边界使用的
    double
    。
  11. 在输入进入此核心之前,将数字和分隔符规范化为 ASCII。 绝不要对原始本地化输入调用
    int.parse
    /
    double.parse
    /
    Decimal.parse
    ——遇到东阿拉伯数字时它们会抛出异常。应在上游完成折叠处理(参见
    i18n-rtl-l10n
    )。
  12. 使用
    decimal
    解析;在边界使用明确的模式且只舍入一次。
    使用
    package:decimal
    进行精确除法和解析,在解析或最终总额边界应用明确的
    RoundingMode
    (默认采用 half-even/银行家舍入)一次,绝不要对中间求和结果进行舍入,否则会累积偏差。
  13. 注入
    package:clock
    的
    Clock
    ;绝不调用
    DateTime.now()
    ,也绝不自行实现
    ClockService
    。
    纯核心中的每个时间读取类都在构造函数中接收
    Clock
    ;Riverpod/feature 代码通过
    clockProvider
    注入同一个
    Clock
    (参见
    service-boundary-and-native
    ),使两套 Clock 术语体系能够组合。固定 Clock 或
    fake_async
    随后可让依赖时间的逻辑在测试中保持确定性。
  14. 核心必须与 Flutter 和 IO 无关。 依赖项只有
    decimal
    和
    clock
    。格式化位于展示层,存储位于数据层。

Canonical storage

Canonical storage

Widgets and repositories exchange value objects, never raw
int
s or strings.
QuantityCanonical storageTypeNever store
Moneyinteger minor units +
Currency
int
double, formatted string
Distancewhole metres
int
km, miles
Volumewhole millilitres
int
litres, gallons
Durationwhole minutes (or
Duration
)
int
hours as double
TimestampUTC ISO-8601 instant
DateTime
(UTC)
local time
dart
/// 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);
}
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
.
Widget 和 repository 交换的是 Value Object,而不是原始
int
或字符串。
数量规范存储类型绝不可存储
Money整数最小货币单位 +
Currency
int
double、格式化字符串
Distance完整的米
int
km、miles
Volume完整的毫升
int
litres、gallons
Duration完整的分钟(或
Duration
)
int
以 double 表示的小时
TimestampUTC ISO-8601 时刻
DateTime
(UTC)
本地时间
dart
/// 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);
}
物理量 Value Object 采用完全相同的结构——一个整数规范字段、会对规范形态进行舍入的
.from<Unit>
工厂,以及仅在边界使用的
to<Unit>()
getter。参见
references/canonical-storage.md
和
examples/money.dart
。

The one division path:
allocate()

唯一的拆分路径:
allocate()

Every time money is split, it goes through this integer largest-remainder (Hamilton) primitive. Never divide money any other way.
dart
/// 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
.
每次拆分金额,都必须使用这个基于整数的 largest-remainder(Hamilton)原语。绝不能以其他任何方式对金额进行除分。
dart
/// 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-spliting.md
和
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.
dart
/// 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
.
输入必须已在上游完成 ASCII 规范化。使用
decimal
解析(不产生二进制误差),按 Currency 指数缩放,并只舍入一次。
dart
/// 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);
}
对于没有固定小数点按键的数字键盘,应直接将数字累加到最小货币单位中,绝不能接触 locale 小数分隔符——参见
references/canonical-storage.md
。

Derive, don't store; inject a Clock

Derive-don't-store;注入 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.
dart
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));
读取时根据项目和权重计算总额;存储总额是漂移问题。任何依赖时间的 Value Object(带日期的汇率、staleness band、到期时间)都接收注入的
Clock
,从而让测试保持确定性。
dart
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 price
    fields.
    Cannot represent
    0.01
    ; corrupts totals irreversibly. Use
    int
    minor units.
  • amount * 100
    /
    cents / 100
    .
    Wrong for every non-2-exponent currency. Route through
    currency.minorPerMajor
    .
  • Defaulting an unknown currency to 2 decimals. Silently mis-scales. Return a typed failure.
  • Adding
    Money
    across 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
    total
    on the entity.
    Derive it.
  • DateTime.now()
    , or a hand-rolled
    ClockService
    /
    SystemClock
    /
    FakeClock
    , inside the pure core.
    Untestable / non-composable time. Inject
    package:clock
    's
    Clock
    .
  • Decimal(someDouble)
    or
    int.parse
    on raw localized input.
    Binary error / throws on Eastern digits. Parse
    Decimal
    from a
    String
    ; normalize first.
  • Rounding intermediate sums. Accumulates bias. Round once at the boundary.
  • double amount
    /
    num price
    字段。
    无法表示
    0.01
    ;会不可逆地破坏总额。使用
    int
    最小货币单位。
  • amount * 100
    /
    cents / 100
    。
    对每个非 2 指数 Currency 来说都是错误的。必须通过
    currency.minorPerMajor
    处理。
  • 将未知 Currency 默认设为 2 位小数。 这会静默地错误缩放。必须返回类型化失败。
  • 跨币种添加
    Money
    ,或者在整个 aggregate 使用单一 Currency 时仍为每个行项目存储 Currency。
    应在 FX 边界转换;Currency 保持在 aggregate 层级。
  • 对分别舍入的份额求和来生成总额。 这会舍入两次并产生漂移。应对已知的整数总额执行
    allocate()
    。
  • 在分配循环内部根据百分比重新计算税费/小费。 先将百分比舍入为整数最小货币单位一次,然后再执行
    allocate()
    。
  • 在实体上存储反规范化的
    total
    。
    应通过计算得出。
  • 在纯核心中调用
    DateTime.now()
    ,或自行实现
    ClockService
    /
    SystemClock
    /
    FakeClock
    。
    时间将无法测试,也无法组合。注入
    package:clock
    的
    Clock
    。
  • 使用
    Decimal(someDouble)
    ,或对原始本地化输入调用
    int.parse
    。
    会产生二进制误差,或在东阿拉伯数字上抛出异常。应从
    String
    解析
    Decimal
    ;先进行规范化。
  • 对中间求和结果进行舍入。 会累积偏差。只在边界舍入一次。

Definition of done

完成标准

  • Every money field is
    int
    minor units + a
    Currency
    ; no
    double
    /
    num
    .
  • All scaling goes through
    currency.minorPerMajor
    ; no literal
    100
    .
  • 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
    int
    s; converted only at the edge.
  • Time-reading classes take an injected
    Clock
    ; no
    DateTime.now()
    in core.
  • Core imports only
    decimal
    and
    clock
    ; no Flutter/intl/dart:io.
  • scripts/check-money-violations.sh
    and
    scripts/verify-core.sh
    pass.
  • 每个金额字段均为
    int
    最小货币单位 +
    Currency
    ;不使用
    double
    /
    num
    。
  • 所有缩放都通过
    currency.minorPerMajor
    完成;不出现字面量
    100
    。
  • 未知 Currency 返回类型化失败,绝不默认解析。
  • 不进行跨币种运算;Currency 位于 aggregate 层级。
  • 每次金额拆分都调用
    allocate()
    ;使用经过验证的测试向量断言并测试守恒性。
  • 百分比在
    allocate()
    之前只舍入为整数最小货币单位一次。
  • 不存储总额;读取时推导总额。
  • 实体携带明确且稳定的
    id
    ;关系采用 id 链接。
  • 物理量以 SI
    int
    存储;仅在边界转换。
  • 时间读取类接收注入的
    Clock
    ;核心中不调用
    DateTime.now()
    。
  • 核心仅导入
    decimal
    和
    clock
    ;不包含 Flutter/intl/dart:io。
  • scripts/check-money-violations.sh
    和
    scripts/verify-core.sh
    通过。

Related skills

相关 skill

  • See
    project-structure-and-packages
    for where this pure core lives (
    lib/core/
    , the sanctioned foundation layer) within the feature-first app layout.
  • See
    service-boundary-and-native
    for injecting this
    Clock
    into Riverpod code via
    clockProvider
    (the same
    package:clock
    seam, provider-wired).
  • See
    error-handling-typed-results
    for the sealed
    Result<T, F extends Failure>
    spine that parsing and FX return instead of throwing.
  • See
    i18n-rtl-l10n
    for ASCII digit/separator normalization and currency formatting at the presentation edge (kept out of this pure core).
  • See
    dart3-idioms-and-coding-standards
    for immutable value types, sealed types, and total non-throwing domain functions.
  • See
    persistence-drift
    for storing minor units + ISO code (never a REAL) and mapping rows to these value objects.
  • See
    testing-strategy
    for the clock-injected, table-driven unit tests these pure functions demand.
  • 参见
    project-structure-and-packages
    ,了解此纯核心在 feature-first app 布局中的位置(
    lib/core/
    ,经过认可的基础层)。
  • 参见
    service-boundary-and-native
    ,了解如何通过
    clockProvider
    将此
    Clock
    注入 Riverpod 代码(使用相同的
    package:clock
    seam,并由 provider 连接)。
  • 参见
    error-handling-typed-results
    ,了解 sealed
    Result<T, F extends Failure>
    主干;解析和 FX 返回该类型,而不是抛出异常。
  • 参见
    i18n-rtl-l10n
    ,了解 ASCII 数字/分隔符规范化,以及位于展示边界的 Currency 格式化(这些逻辑不进入纯核心)。
  • 参见
    dart3-idioms-and-coding-standards
    ,了解不可变 Value Type、sealed type,以及完全非抛异常的 domain function。
  • 参见
    persistence-drift
    ,了解如何存储最小货币单位 + ISO 代码(绝不存储 REAL),并将数据行映射到这些 Value Object。
  • 参见
    testing-strategy
    ,了解这些纯函数所需的、注入 Clock 且采用 table-driven 风格的单元测试。

References

参考资料