FEN — Monetary Arithmetic & Rounding (§3.11)
Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.
3.11 Monetary Arithmetic & Rounding Rules
All balance calculations are derived by replaying events through the projector on every client device. Correctness is defined as: same event log → same balances, on every device, forever. This section pins the arithmetic rules that make that guarantee hold.
Storage Type
All monetary amounts are stored and processed as signed integers in minor units. No floating-point types are used anywhere in financial logic.
Floating-point arithmetic is platform-sensitive: Dart-native and Dart-web (which uses a 53-bit JS mantissa) can produce different results for the same operation. In a system where determinism is a hard requirement, double is forbidden for amounts, splits, balances, or intermediate calculations.
// ✅ Correct — store and compute in minor units
const int amount = 1234; // CHF 12.34 — scale 2
// ❌ Wrong — floats are not safe for financial arithmetic
const double amount = 12.34; // may not round-trip; never use
Currency scale is a property of the currency code, not the amount. Derive it from a canonical lookup table shipped with the app:
| Currency | Code | Scale | Minor unit |
|---|---|---|---|
| Swiss Franc | CHF | 2 | centime |
| US Dollar | USD | 2 | cent |
| Japanese Yen | JPY | 0 | yen |
| Kuwaiti Dinar | KWD | 3 | fils |
| Bitcoin (app-defined) | BTC | 8 | satoshi |
Scale is not stored per-event; it is derived from currencyCode on read. All devices use the same lookup table (versioned with the app).
Dart int is 63-bit on native and sufficient for all standard currencies and BTC. For Ethereum denominated in wei (scale 18), use BigInt — or denominate in gwei/microETH to fit int. Prefer int unless BigInt is explicitly required; BigInt is slower and larger.
Serialise amounts as decimal strings, not JSON numbers. JSON numbers are parsed as floats by many runtimes, silently introducing the error we're avoiding.
Parsing User Decimal Input
Never parse user-entered amounts through double. Use string splitting:
/// Parse "12.34" → 1234 for scale=2. Rejects too many decimal places.
int parseAmount(String input, int scale) {
final parts = input.split('.');
final whole = int.parse(parts[0]);
final fracStr = parts.length > 1 ? parts[1] : '';
if (fracStr.length > scale) {
throw FormatException('Too many decimal places for this currency');
}
final frac = int.parse(fracStr.padRight(scale, '0'));
return whole * pow(10, scale).toInt() + frac;
}
Split Allocation — Strategy A (Resolved Amounts in Event)
The creating device runs the split algorithm exactly once and stores the resolved per-member integer amounts in the event. The projector performs only integer addition during replay — no division, no rounding.
{
"type": "expense_added",
"id": "evt_01HXYZ",
"originalAmountMinorUnits": 12500,
"originalCurrencyCode": "JPY",
"settlementAmountMinorUnits": 8600,
"settlementCurrencyCode": "CHF",
"splits": {
"mbr_alice": 2867,
"mbr_bob": 2867,
"mbr_carol": 2866
}
}
// ¥12,500 at ~145 JPY/CHF ≈ CHF 86.00 = 8,600 centimes (CHF scale 2) // sum(splits) = 2867 + 2867 + 2866 = 8,600 ✓
When the original and settlement currencies are the same, both amount fields carry identical values. The optional splitSpec field (formula type, inputs) may be stored alongside allocations for UI display and audit, but the projector uses only the resolved integer amounts in the settlement currency.
This eliminates algorithm versioning risk: even if the rounding algorithm changes in a future app version, old events replay correctly because their resolved amounts are immutable.
Equal Splits — Largest-Remainder Method
For N equal shares of totalMinorUnits:
base = totalMinorUnits ~/ N // integer division (floor)
remainder = totalMinorUnits % N // 0 … N−1 extra minor units
Sort members by ascending lexicographic member ID. Assign base + 1 to the first remainder members; base to the rest. The sum is exactly totalMinorUnits.
Map<String, int> splitEqually(int totalMinorUnits, List<String> memberIds) {
assert(memberIds.isNotEmpty);
final sorted = [...memberIds]..sort();
final base = totalMinorUnits ~/ sorted.length;
final remainder = totalMinorUnits % sorted.length;
return {
for (var i = 0; i < sorted.length; i++)
sorted[i]: i < remainder ? base + 1 : base,
};
}
The lexicographic sort is deterministic across all platforms without any external state. The alphabetically-first member IDs receive the extra minor unit(s); this is documented as a spec invariant, not an implementation detail.
Weighted Splits (Shares)
Map<String, int> splitByShares(int total, Map<String, int> shares) {
final totalShares = shares.values.reduce((a, b) => a + b);
assert(totalShares > 0);
final sorted = shares.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
// floor allocation and fractional numerator for each member
final floors = sorted.map((e) => total * e.value ~/ totalShares).toList();
final fracNumer = sorted.map((e) => total * e.value % totalShares).toList();
var remaining = total - floors.reduce((a, b) => a + b);
// distribute remainder to largest fractional remainders; tie-break by member ID
final order = List.generate(sorted.length, (i) => i)
..sort((a, b) {
final cmp = fracNumer[b].compareTo(fracNumer[a]);
return cmp != 0 ? cmp : sorted[a].key.compareTo(sorted[b].key);
});
for (var i = 0; i < remaining; i++) floors[order[i]]++;
return { for (var i = 0; i < sorted.length; i++) sorted[i].key: floors[i] };
}
Intermediate arithmetic uses only integer multiplication and division — no floats.
Percentage Splits
Store percentages as integer basis-points (1 % = 100 bps; 100 % = 10,000 bps). The creating device is responsible for ensuring all basis-point values sum to exactly 10,000. Pass the basis-point values as shares into splitByShares with totalShares = 10,000. There is no algorithmic difference.
Do not store 33.33 as a float. Store 3333, 3333, 3334.
Exact-Amount Splits
The user enters per-member amounts directly. Validate before writing any event:
assert(splits.values.reduce((a, b) => a + b) == totalMinorUnits,
'Splits must sum to total');
If they don't sum, reject the input at the UI layer. Never silently round or adjust a member's share.
Multi-Currency & Settlement Currency
Every group declares a settlement currency at creation. This is the single currency used for all balance calculations and settlement flows. It is stored in the group-creation event and never changes.
Each expense stores two monetary values:
| Field | What it captures | Role |
|---|---|---|
originalAmountMinorUnits + originalCurrencyCode | What was actually paid (any ISO 4217 currency) | Display, audit, receipt |
settlementAmountMinorUnits + settlementCurrencyCode | Equivalent value in the group's settlement currency | All balance projection and debt calculation |
When the original and settlement currencies are the same, both amounts are identical values.
The settlement amount is a user-confirmed integer stored in the event — not computed at projection time. The UI may suggest a settlement amount using a locally-fetched display rate, but the user must confirm the value before the event is signed. Once committed to the log the amount is immutable. The FX rate itself is never stored — only the two integer amounts. The implied rate is derivable for display but plays no role in any calculation.
All balance projections use settlementAmountMinorUnits exclusively. Balances are scalars in the settlement currency:
// Bilateral pairwise balance: positive = first person is owed money
typedef PairwiseBalance = Map<(String payer, String payee), int>; // settlement-currency minor units
Debt simplification, settlement suggestions, and all "you owe / you are owed" summaries operate on settlement-currency integers only. There are no multi-currency balance vectors; the settlement currency collapses the accounting into a single scalar dimension.
Settlement events (payments between members) are denominated in the settlement currency. The app records settlements in the group's settlement currency regardless of how the underlying bank transfer was made. Recording the bank transfer's actual currency is an optional note field, not a balance-affecting field.
Display-only FX rate: The UI may fetch a rate to suggest a settlement amount while the user is entering an expense. This value is advisory, must be clearly labeled as approximate, and is never written to the event log.
Edge Cases
| Case | Rule |
|---|---|
| Zero-amount split | Valid. A member may have a split of 0. Sum invariant still holds. |
| All-zero total | Every member allocation must be 0. |
| Very large groups | Largest-remainder assigns ≤ N−1 extra minor units. No scaling issue. |
| BTC (scale 8) | Fits int64. Use int. |
| ETH in wei (scale 18) | Overflows int64. Use Dart BigInt, or denominate in gwei (scale 9). |
| Input with too many decimals | Reject before event creation. Do not truncate or round silently. |
Invariants
| ID | Invariant |
|---|---|
| INV-ARITH-1 | All monetary amounts in the event log and projector are integers in minor units. No double for amounts, splits, or balances. |
| INV-ARITH-2 | For every expense event: sum(splits.values) == settlementAmountMinorUnits. Splits are denominated in the settlement currency. Events violating this invariant are rejected at write time and skipped at read time. |
| INV-ARITH-3 | Split events store resolved per-member integer amounts. The projector performs no division or rounding during replay. |
| INV-ARITH-4 | Member IDs are stable UUIDs. They never change. All split references use IDs, not display names or list indexes. |
| INV-ARITH-5 | Every expense event stores both originalAmountMinorUnits/originalCurrencyCode (for display and audit) and settlementAmountMinorUnits/settlementCurrencyCode (for projection). The projector uses only settlement amounts. Original amounts are never added to, subtracted from, or compared with settlement amounts in any calculation. |
| INV-ARITH-6 | Currency scale is derived from a single canonical lookup table shipped with the app. It is not stored per-event. |
| INV-ARITH-7 | When resolving equal splits on the creating device, remainder minor units are assigned to members in ascending lexicographic member-ID order. |
| INV-ARITH-8 | A member split of 0 is valid and is not an error. |
| INV-ARITH-9 | Individual member split amounts within one expense event are ≥ 0. Negative balances emerge from the net of multiple events, not from negative splits within one event. |
| INV-ARITH-10 | Projector output must not depend on locale, timezone, map iteration order, device type, or app version, except through explicit event-schema versioning. |
| INV-ARITH-11 | The settlementCurrencyCode in every expense and payment event must match the group's declared settlement currency. Events with a mismatched settlement currency are rejected at write time and skipped at read time. The group's settlement currency is set at creation and never changed. |
CI gate: a golden test suite must exist covering equal splits, weighted splits, exact splits, zero amounts, negative totals (refunds), high-precision assets, and remainder tie-breaking. These tests must pass on Dart native and Dart web (flutter test --platform chrome).