Finance Core
One Score, Many Calculators
Every earlier post in this series built one small, pure, independently-tested calculator — a savings rate, a cadence, a payoff simulation, a goal projection. KPIEngine.makeReport is where all of them get called once, assembled into a single KPIReport, and boiled down to the handful of signals a person actually sees at a glance: one health rating, six trending tiles, and a short list of things worth their attention.
A report, not five numbers
The dashboard's real headline tiles are net cash flow, savings rate, runway, total debt, monthly recurring outflow, and debt-to-income — six, not five — plus a net worth chart that isn't a tile at all. But KPIReport itself carries far more than that: the largest subscriptions, what's due in the next 30 days, weighted average APR, subscription price creep over the trailing year, a category-by-category spend breakdown, budget lines, a discretionary-vs-fixed spending split, and the trend and anomaly data behind all of it. The dashboard tiles are a curated view onto one report — not the full extent of what the engine actually computes.
Small helpers, assembled once
Most of what feeds into KPIReport is a tiny, single-purpose function reused everywhere the same shape of question comes up — "what fraction of income does this represent," asked of a category, of recurring spend, of debt service:
public enum IncomeShare {
/// `amount / income`; nil when there's no income to divide by.
public static func fraction(of amount: Decimal, income: Decimal) -> Double? {
income > 0 ? (amount / income).doubleValue : nil
}
}
A category budget, a discretionary/fixed split — BudgetMath and FlexSpendMath are each barely more than this, one small pure function reused wherever that exact shape of ratio comes up, all sharing the same "nil instead of a divide-by-zero" convention that savingsRate and runway already established two posts ago.
Trends come from the past, not from re-asking today
A month-over-month trend needs to know what a metric was a month ago. The tempting shortcut is calling the same calculator again with an earlier asOf date — and it's wrong, because today's live subscriptions and account balances aren't what existed a month ago. KPIReport instead reads from KPISnapshot, one row written per calendar month, explicitly to avoid that exact mistake:
/// One calendar month's snapshot of the Dashboard's headline metrics — the
/// only honest way to trend metrics that depend on today's live
/// subscriptions/accounts (recomputing "as of" a past date from today's
/// state would silently misrepresent history).
public struct KPISnapshotPoint {
public var date: Date
public var netWorth: Decimal
public var totalDebt: Decimal
public var netCashFlow: Decimal
public var savingsRate: Double?
public var runwayMonths: Double?
public var monthlyRecurringOutflow: Decimal
public var debtToIncome: Double?
}
Past months are permanent — never rewritten. The current month is the one exception: it's refreshed every time the dashboard appears, so editing an account balance today moves today's own point immediately instead of waiting for the month to close. The write happens right after the report is built, using the report's own already-computed numbers, so there's no risk of a second, slightly different calculation drifting out of sync with what the dashboard just displayed:
// Build the report first — its trends only ever compare against
// already-stored past months, so it's safe to read before this
// month's own KPISnapshot row is written just below.
let r = KPIService.buildReport(context: context, currencyCode: settings.currencyCode, overallMonthlyBudget: settings.overallMonthlyBudget)
SnapshotService.recordIfNeeded(context: context, report: r)
Two shapes of "change," not one
A currency amount and a rate that's already a fraction don't compare the same way. Net worth going from $10,000 to $11,000 is a 10% increase, and that's a meaningful sentence. A savings rate going from 20% to 22% is not "a 10% increase" in any sentence a person would actually say — it's a two-point rise. TrendMath keeps these as two separate functions rather than forcing one formula to serve both:
/// `(current - previous) / |previous|` — for amounts (net worth, debt, cash flow).
public static func percentChange(current: Decimal, previous: Decimal?) -> Double? {
guard let previous, previous != 0 else { return nil }
return ((current - previous) / abs(previous)).doubleValue
}
/// A plain difference — for metrics that are already a fraction or a month
/// count (savings rate, runway, DTI). A %-of-a-% reads as ambiguous.
public static func pointDelta(current: Double?, previous: Double?) -> Double? {
guard let current, let previous else { return nil }
return current - previous
}
Every headline tile picks whichever of the two actually describes it — currency and count-based metrics get percentChange, already-fractional ones get pointDelta — rather than one generic "delta" concept papering over the difference.
"Up" doesn't get a shared abstraction — it gets six inline decisions
Net cash flow trending up is good. Total debt trending up is bad. Nothing in LedgerCore encodes that distinction as a reusable type — it's just decided once, inline, at each tile's own call site in the dashboard view:
KPITile(title: "Net cash flow", trendIsGood: r.trends.netCashFlow.momDelta.map { $0 >= 0 }, /* ... */)
KPITile(title: "Total debt", trendIsGood: r.trends.totalDebt.momDelta.map { $0 <= 0 }, /* ... */)
KPITile(title: "Monthly recurring", trendIsGood: r.trends.monthlyRecurringOutflow.momDelta.map { $0 <= 0 }, /* ... */)
KPITile(title: "Debt-to-income", trendIsGood: r.trends.debtToIncome.momDelta.map { $0 <= 0 }, /* ... */)
Six tiles, six one-line decisions, no shared "polarity" enum threading through the engine. It's a smaller solution than a generic abstraction would be, and it's enough — there are exactly six of these, they don't change often, and writing each one out means a reviewer sees the actual direction at the call site instead of chasing it through a shared indirection.
One rolled-up score, not per-tile stoplights
Individual tiles get a trend arrow, and occasionally a simple sign-based tint — net cash flow's icon is green above zero, red below. But nothing per-tile carries its own independent good/warning/critical status. Instead, exactly one combined signal sits at the top of the whole dashboard: a FinancialHealth rating, from a small weighted score across four inputs.
static func rateHealth(savingsRate: Double?, dti: Double?, runwayMonths: Double?, netCashFlow: Decimal) -> FinancialHealth {
var score = 0
switch savingsRate {
case .some(let s) where s >= 0.2: score += 2
case .some(let s) where s >= 0.1: score += 1
case .some(let s) where s < 0: score -= 2
default: break
}
switch dti {
case .some(let d) where d <= 0.2: score += 2
case .some(let d) where d <= 0.36: score += 1
case .some(let d) where d > 0.5: score -= 2
case .some: score -= 1
default: break
}
switch runwayMonths {
case .some(let r) where r >= 6: score += 2
case .some(let r) where r >= 3: score += 1
case .some(let r) where r < 1: score -= 2
case .some: score -= 1
default: break
}
if netCashFlow < 0 { score -= 1 }
switch score {
case 5...: return .strong
case 2...4: return .stable
case 0...1: return .watch
default: return .strained
}
}
No single bad number tanks the rating on its own, and no single good one rescues it either — a thin runway can be offset by a strong savings rate and low debt, which is closer to how someone would actually judge "am I okay" than any one metric in isolation. The 36% DTI threshold isn't arbitrary either — it's the standard mortgage-underwriting guideline for debt-to-income, reused here as a sensibility check rather than invented for the app.
Anomalies: the specific things worth naming
The health score answers "how am I doing, overall." A separate anomaly list answers "is there something specific I should look at" — and for spending spikes, it's not just a flat percentage threshold:
for c in categorySpend {
guard c.trailingMonthlyAverage > 0 else { continue }
let deltaRatio = ((c.currentMonth - c.trailingMonthlyAverage) / c.trailingMonthlyAverage).doubleValue
let zScore = c.trailingStdDev > 0
? ((c.currentMonth - c.trailingMonthlyAverage) / c.trailingStdDev).doubleValue
: 0
if deltaRatio >= 0.4 && (c.trailingStdDev == 0 || zScore >= 2) {
// flag: category spend is up
}
}
A category has to be up at least 40% and either have no real variance to speak of, or be at least two standard deviations above its own trailing average — a category that normally swings wildly needs a bigger surprise to get flagged than one that's usually rock-steady. That standard deviation is computed with the same sample (Bessel-corrected) formula a statistics textbook would use, not an approximation. The rest of the anomaly list is simpler, flat thresholds: a category or overall budget at 90% (info) or 100%+ (warning), runway under 3 months, DTI over 36%, negative cash flow, or savings rate under 10% — each one a fact stated plainly, not folded into the health score's arithmetic.
A hash, because the AI layer needs to know when to shut up and reuse an answer
Every KPIReport carries a fingerprint — a stable hash of its own contents — and the reason it isn't just Swift's built-in Hasher is a real, specific gotcha: Hasher's output is randomized per process launch, by design, for DoS resistance, so the same report would hash differently every time the app relaunches. StableHash uses FNV-1a instead, a small dependency-free hash that gives the same output for the same input every time:
public static func fnv1a(_ string: String) -> UInt64 {
var hash: UInt64 = 0xcbf29ce484222325
let prime: UInt64 = 0x100000001b3
for byte in string.utf8 {
hash ^= UInt64(byte)
hash = hash &* prime
}
return hash
}
That fingerprint is the cache key for the on-device AI narrative: the model only has to write fresh commentary when the numbers underneath it actually changed. It's a small, fitting bookend for this whole series — every calculator in it, from a bankers'-rounded interest charge to a weighted health score, exists so that when the model finally speaks, it's narrating something Swift already got right, not guessing.