Value Objects Money And Units logo

Value Objects Money And Units

Community
zakariaf
value-objects-money-and-units

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.

Overview

Publisherzakariaf
RepositoryFlutter-Skills
Skill namevalue-objects-money-and-units
Stars
64
Forks
15
Bundled files
7
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 7 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by zakariaf on GitHub. Read the source before you install it.

Installation

Install the Value Objects Money And Units AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/zakariaf/Flutter-Skills/tree/main/skills/value-objects-money-and-units
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/zakariaf/Flutter-Skills.git /tmp/Flutter-Skills
mkdir -p .claude/skills
cp -r /tmp/Flutter-Skills/skills/value-objects-money-and-units .claude/skills/value-objects-money-and-units
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Value Objects Money And Units in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Value Objects Money And Units on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Value Objects Money And Units is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Value objects: money & units

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.

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.

Canonical storage

Widgets and repositories exchange value objects, never raw ints or strings.

QuantityCanonical storageTypeNever store
Moneyinteger minor units + Currencyintdouble, formatted string
Distancewhole metresintkm, miles
Volumewhole millilitresintlitres, gallons
Durationwhole minutes (or Duration)inthours as double
TimestampUTC ISO-8601 instantDateTime (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}');

  @override
  int compareTo(Money o) {
    assert(currency == o.currency, 'compare across currencies is a bug');
    return minorUnits.compareTo(o.minorUnits);
  }

  @override
  bool operator ==(Object o) =>
      o is Money && o.minorUnits == minorUnits && o.currency == currency;
  @override
  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.

The one division path: 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.

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.

Derive, don't store; inject a 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));

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.

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 ints; 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.

Related skills

  • 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.

References

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Value Objects Money And Units AI skill do?

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...

Why use Value Objects Money And Units on TypingMind?

Because you install it once and use it with any model. Value Objects Money And Units is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Value Objects Money And Units in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/zakariaf/Flutter-Skills/tree/main/skills/value-objects-money-and-units. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Value Objects Money And Units?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Value Objects Money And Units?

As many as you like. As long as a model supports skills, you can use Value Objects Money And Units with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Value Objects Money And Units AI skill free?

Yes. It is published on GitHub by zakariaf under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇