Finance Core
The Rules Money Follows
Tally's finance engine lives in its own Swift package, LedgerCore — pure functions and value types, no SwiftData, no UI, unit-tested on its own. Before any of the math in this series (savings rate, amortization, goal pacing) means anything, every number flowing into it has to obey a short list of rules about what a "correct" amount even is. This post is those rules.
Decimal, never Double, and no exceptions for the hard parts
LedgerCore's own doc comment is blunt about it: "All monetary values in LedgerCore are Decimal — never Double." Double is a binary floating-point approximation; it will tell you three payments of $6.63 against $19.90 leave $0.010000000000005. Decimal represents money exactly, in base 10, the way a receipt does.
The rule holds even where it's inconvenient. The standard loan-payment formula needs (1 + r)^n, and Decimal has no pow. The tempting shortcut — cast to Double, call pow, cast back — reintroduces exactly the float error the whole codebase is built to avoid, for a number that ends up as someone's actual monthly payment. Instead, LedgerCore writes integer exponentiation by hand, entirely in Decimal, via binary exponentiation:
extension Decimal {
/// Integer exponentiation kept fully in `Decimal` (no `pow`/`Double`).
/// `exponent` must be >= 0.
func raised(to exponent: Int) -> Decimal {
precondition(exponent >= 0, "raised(to:) supports non-negative exponents only")
if exponent == 0 { return 1 }
var result: Decimal = 1
var base = self
var e = exponent
while e > 0 {
if e & 1 == 1 { result *= base }
base *= base
e >>= 1
}
return result
}
}
This is the same doubling trick behind fast modular exponentiation — square the base each round, only multiply it into the result on the bits that are set — except every intermediate value stays exact. A 30-year mortgage's (1+r)^360 comes out of this the same way a receipt total would: no rounding until something is explicitly rounded.
Rounding is a decision, not an accident
Explicit rounding, everywhere it happens, using round-half-to-even ("bankers' rounding") rather than the round-half-up most people learn in school:
public enum Money {
public static let defaultScale = 2
public static func rounded(
_ value: Decimal,
scale: Int = defaultScale,
mode: NSDecimalNumber.RoundingMode = .bankers
) -> Decimal {
var input = value
var result = Decimal()
NSDecimalRound(&result, &input, scale, mode)
return result
}
/// Non-negative clamp — a balance or payment can never be below zero.
public static func clampNonNegative(_ value: Decimal) -> Decimal {
value < 0 ? 0 : value
}
}
Round-half-up biases every exact-.5 case upward, and that bias compounds — over a 360-period amortization schedule, rounding every month's interest up instead of to-even measurably inflates the total. Round-half-to-even alternates which way a tie falls, so the errors cancel out instead of accumulating. Money.rounded is the only place rounding happens in the engine; a balance, an interest charge, an annuity payment — every one of them passes through it once, explicitly, rather than picking up incidental rounding from wherever it happens to get printed.
A transaction's amount is never negative
The obvious way to model a signed ledger is to store the sign on the amount: positive for income, negative for expenses. Tally's real Transaction model does the opposite — amount is always stored positive, and the sign is a computed property derived from a kind:
enum TransactionKind: String {
case expense
case income
var sign: Decimal { self == .expense ? -1 : 1 }
}
// amount: Decimal // always stored positive; sign implied by `kind`
// kind: TransactionKind
var signedAmount: Decimal { kind.sign * amount }
This isn't just style. Every editing screen, CSV import, and quick-add template only ever has to ask "how much, and was it money in or money out" — there's no separate way to enter a positive expense by mistake and quietly flip every downstream total. The sign lives in exactly one place: the kind enum's sign, not in a value someone had to remember to negate at entry time.
Where double-counting actually sneaks in
The obvious double-counting risk in a ledger — moving money between two of your own accounts — doesn't exist in Tally's data model at all: there's no transfer transaction kind, only expense and income. The real double-counting risks are subtler, and both follow the same shape: a field that's either a single value or a computed alternative, never both at once.
The first is split transactions. A transaction's own amount and category stay populated even once it's been split across several categories — they're a "kept-in-sync cache of the sum," so every existing reader (KPIs, search, CSV export) that only knows how to read tx.amount keeps working for the total. But a per-category reader that also reads tx.amount/tx.category directly on a split transaction, instead of going through its splits, will attribute the whole amount to whatever category happened to be cached there — silently double-counting against the categories the split actually spread it across. The fix is a single choke point every per-category consumer has to go through:
/// One (category, amount) pair per line a transaction contributes to a
/// category total: the transaction itself when it isn't split, or one
/// entry per split when it is.
static func categoryLines(for tx: Transaction) -> [(category: Category?, amount: Decimal)] {
guard let splits = tx.splits, !splits.isEmpty else {
return [(tx.category, tx.amount)]
}
return splits.map { ($0.category, $0.amount) }
}
The second is an investment account's balance. An account is either a manually-typed balance or a sum of its holdings' market values — never both, and the switch between them is automatic:
/// The balance every net-worth-affecting reader should use. An account
/// is either manual-balance (the default) or holdings-tracked (the sum
/// of its holdings' market values) — never both, so net worth never
/// double-counts. Adding the first holding switches it over; removing
/// the last switches it back.
var effectiveBalance: Decimal {
let positions = holdings ?? []
return positions.isEmpty ? currentBalance : positions.reduce(Decimal(0)) { $0 + $1.marketValue }
}
Every reader of an account's balance — net worth, runway, a goal linked to that account — goes through effectiveBalance, never the raw currentBalance field directly. Read the wrong field on a holdings-tracked account and you'd either miss its value entirely or, if someone had also been keeping currentBalance updated by hand, count it twice.
A reconciliation is a checkpoint, not a fix
Tally has no bank connections — every transaction is typed in by hand, which means an account's stored balance and its real one can quietly drift. Reconciling doesn't try to find and repair the error:
public struct ReconciliationResult {
public var expectedBalance: Decimal
public var actualBalance: Decimal
public var discrepancy: Decimal { actualBalance - expectedBalance }
public var isReconciled: Bool { discrepancy == 0 }
}
public enum Reconciliation {
public static func check(
lastReconciledBalance: Decimal = 0,
netTransactionsSince: Decimal = 0,
actualBalance: Decimal
) -> ReconciliationResult {
ReconciliationResult(
expectedBalance: lastReconciledBalance + netTransactionsSince,
actualBalance: actualBalance
)
}
}
"Expected" is just the last checkpoint plus everything logged since. If it doesn't match what's actually in the account, discrepancy says by how much — and reconciling always re-baselines at the current balance anyway, whether or not it matched, because there's nothing to auto-correct in a manual-entry app. It's a checkpoint, not a correction: the discrepancy is information for the person, not an error LedgerCore tries to resolve on its own.
Next
With money handled correctly at rest, the next post covers the first thing built on top of it: savings rate and runway, and why the raw inputs to those formulas already have to be smoothed before the division ever happens.