Finance Core
Savings Rate & Runway
Two numbers on Tally's dashboard look like one-line divisions: savings rate is cash flow / income, runway is liquid assets / burn. They are one-line divisions, in KPIEngine.makeReport — but almost everything interesting happens in what gets divided, not in the division itself.
Income is whichever signal is stronger
A salaried user has income sources set up with a cadence and an amount — steady, known in advance. A freelancer has none of that, just irregular income transactions. KPIService doesn't pick one model; it computes both and takes the larger:
// Income: recurring sources plus a trailing average of income transactions,
// whichever signal is stronger.
let recurringIncome = incomeSources
.filter(\.isActive)
.reduce(Decimal(0)) { $0 + $1.monthlyEquivalent }
let incomeTxAvg = trailingMonthlyAverage(
transactions.filter { $0.kind == .income }, months: 3, asOf: asOf, calendar: calendar
)
let monthlyIncome = max(recurringIncome, incomeTxAvg)
Neither side is assumed to be complete on its own. Someone who logs a paycheck as an income source but also occasionally records freelance income as plain transactions gets credit for whichever total is bigger that month, rather than the app silently picking the wrong one of two partial pictures.
Expenses are three different bases, added together
Recurring outflow (subscriptions, normalized through Cadence.monthlyEquivalent — covered next post), a trailing 3-month average of one-off spend, and debt minimums, summed into one expense total:
let monthlyRecurring = Money.rounded(activeSubs.reduce(Decimal(0)) { $0 + $1.monthlyEquivalent })
let oneOff = Money.rounded(snapshot.oneOffMonthlyExpenseAverage) // trailing 3-month average
let monthlyDebtService = Money.rounded(snapshot.debts.reduce(Decimal(0)) { $0 + $1.minimumPayment })
let monthlyExpenseTotal = Money.rounded(monthlyRecurring + oneOff + monthlyDebtService)
Only the one-off bucket is trailing-averaged, and that's deliberate: subscriptions are already a stable monthly-equivalent figure, and debt minimums are contractual, not empirical — there's nothing noisy about them to smooth. The volatility lives entirely in one-off spending, so that's the only input actually averaged before it feeds the total. A single large one-off expense — an annual insurance bill, a big irregular purchase — gets absorbed into three months of averaging right here, before it ever reaches a ratio, rather than distorting a single month's savings rate on its own.
private static func trailingMonthlyAverage(
_ transactions: [Transaction], months: Int, asOf: Date, calendar: Calendar
) -> Decimal {
guard months > 0 else { return 0 }
let start = calendar.date(byAdding: .month, value: -months, to: asOf) ?? asOf
let total = transactions
.filter { $0.date >= start && $0.date <= asOf }
.reduce(Decimal(0)) { $0 + $1.amount }
return Money.rounded(total / Decimal(months))
}
Savings rate: a ratio, or nothing
With income and expenses already resolved, the ratio itself is one line — and it returns nil, not 0, when there's no income to divide by:
let netCashFlow = Money.rounded(income - monthlyExpenseTotal)
let savingsRate: Double? = income > 0 ? (netCashFlow / income).doubleValue : nil
nil and 0 mean different things here, and collapsing them would be a real mistake: a savings rate of 0 is a fact about someone's finances (they're saving nothing), while nil is a fact about the engine (there's no income on record to compute a rate against at all). The dashboard renders the second case as "—", not "0%" — a blank is honest about not knowing; a zero would claim something false.
Runway: the same nil-safety, with a narrower "liquid"
Runway divides liquid assets by the burn rate — but "liquid" is a specific, curated set of account kinds, not every dollar to your name:
/// Whether balances of this kind count as "liquid" for runway.
var isLiquid: Bool {
switch self {
case .checking, .savings, .cash: return true
case .investment, .other: return false
}
}
let liquidAssets = accounts.filter { $0.kind.isLiquid }.reduce(Decimal(0)) { $0 + $1.effectiveBalance }
let burn = Money.rounded(monthlyExpenseTotal)
let runway: Double? = burn > 0 ? (liquidAssets / burn).doubleValue : nil
An investment account can be worth a lot and still be useless for covering next month's rent without selling something and waiting for it to settle, so it's deliberately excluded from the figure that's supposed to answer "how long could I coast." Runway is nil under the same rule as savings rate — a burn rate of zero or less isn't "forever," it's a number with nothing meaningful to say, and the dashboard shows "—" there too rather than inventing an infinity.
The threshold lives somewhere else entirely
Neither function above judges whether its own result is good or bad — savingsRate and runway just answer the arithmetic question, honestly, with nil when there's nothing to answer. A runway under three months does get flagged elsewhere in the engine, as a warning-level anomaly — but that's a separate concern, layered on afterward, covered in the post on how the dashboard's health score and anomaly list actually get built.
Next
Cadence.monthlyEquivalent did some quiet work above, folding a subscription's real billing schedule into a single comparable monthly figure. The next post is about that type in full: how Tally represents a recurring charge, and the actual (and slightly surprising) way Calendar behaves when you ask it to add a month to a date.