Finance Core
Debt Payoff: Avalanche vs. Snowball
Tally answers two different debt questions, and they need two different tools. "What does paying off this loan look like" is a fixed-rate amortization schedule for one debt. "Given everything I can put toward debt this month, in what order should it go" is a multi-debt simulation across all of them at once. They live in the engine as Amortization and DebtPlanner, and neither one is a special case of the other.
One loan: Amortization
A mortgage, an auto loan, a personal loan — anything with a fixed schedule — gets modeled with LoanTerms and walked forward one period at a time:
public struct LoanTerms {
public var currentBalance: Decimal
public var annualRate: Decimal // fraction, e.g. 0.0645
public var scheduledPayment: Decimal
public var extraPayment: Decimal = 0
public var firstPaymentDate: Date
}
public enum Amortization {
static let maxPeriods = 1_200 // 100 years
public static func schedule(_ terms: LoanTerms, calendar: Calendar = .current) throws -> AmortizationResult {
let monthlyRate = terms.annualRate / 12
let firstInterest = Money.rounded(terms.currentBalance * monthlyRate)
let totalMonthlyPayment = terms.scheduledPayment + terms.extraPayment
// A zero-interest loan always amortises. Otherwise the payment must
// beat the first interest charge, checked once, up front.
if monthlyRate > 0, totalMonthlyPayment <= firstInterest {
throw AmortizationError.paymentDoesNotCoverInterest(
minimumViable: Money.rounded(firstInterest + 0.01)
)
}
// ...walk balance forward one month at a time, capped at maxPeriods,
// rounding every interest charge through Money.rounded as it accrues.
}
}
Notice where the safety check happens: before the simulation runs at all, not as a loop guard discovered the hard way. If a $50 payment can't even cover this month's interest on a $100,000 balance, schedule throws AmortizationError.paymentDoesNotCoverInterest immediately, and tells the caller the minimum payment that would actually work. DebtDetailView calls this with try? and just shows nothing if it fails — a debt that can never amortize at its current terms simply doesn't get a projected payoff chart, rather than the app hanging on a schedule that runs for a "very long time."
The same balance/rate math, without materializing a whole schedule, splits a single real payment someone is about to log:
public static func paymentSplit(balance: Decimal, annualRate: Decimal, payment: Decimal) -> (interest: Decimal, principal: Decimal) {
let interest = Money.rounded(Money.clampNonNegative(balance) * max(0, annualRate) / 12)
let rawPrincipal = payment - interest
let principal = min(max(0, rawPrincipal), Money.clampNonNegative(balance))
return (interest, Money.rounded(principal))
}
This is what actually runs when someone logs a debt payment — principal is clamped to 0...balance, so an overpayment can't drive the balance negative and a payment smaller than the interest charge yields zero principal rather than a negative one.
A debt payment isn't a Transaction
Logging a debt payment writes a separate DebtPayment record and reduces the debt's balance directly — it deliberately does not create an ordinary transaction. Debt service is already counted once, as each debt's minimumPayment, when the dashboard totals up monthly expenses; if paying down a card also posted as an expense transaction, that same money would be counted as spend twice.
Several loans: DebtPlanner
Avalanche and snowball only make sense once there's more than one debt competing for the same dollars. DebtPlanner.simulate takes every debt and a single monthlyBudget — not a separate "minimums" pool and "extra" pool — and works through it month by month:
public enum PayoffStrategy: String {
case avalanche // highest interest rate first
case snowball // smallest balance first
case minimumOnly
}
while !remaining().isEmpty && month < Self.maxMonths {
month += 1
let active = remaining()
// 1. Accrue interest.
for id in active {
let interest = Money.rounded((balances[id] ?? 0) * (rateByID[id] ?? 0) / 12)
balances[id, default: 0] += interest
totalInterest += interest
}
// 2. Minimum payments on everything, in order, from one shared budget.
var budget = monthlyBudget
for id in active {
let bal = balances[id] ?? 0
let pay = min(bal, min(minByID[id] ?? 0, budget))
balances[id] = bal - pay
budget -= pay
if budget <= 0 { break }
}
// 3. Whatever's left goes to the current strategy's target order —
// recomputed fresh from this month's balances.
if strategy != .minimumOnly {
for id in targetOrder(remaining()) {
guard budget > 0 else { break }
let bal = balances[id] ?? 0
guard bal > 0 else { continue }
let pay = min(bal, budget)
balances[id] = bal - pay
budget -= pay
}
}
}
Two things about this are easy to get wrong in your head before reading the actual loop. First: the minimum-payments step draws from the same budget as everything else, in the order the debts happen to be listed — if the budget can't cover every minimum, whichever debt is later in that list gets short-changed or skipped that month. A workable plan has to fund at least the sum of all minimums; DebtPlanner.minimumMonthlyObligation exists specifically to tell the UI what that floor is.
Second, and more surprising: targetOrder is recomputed every single month, from that month's current balances — not decided once at the start and locked in. For avalanche this barely matters in practice, since interest rates don't change; for snowball it's actually the point. "Smallest balance first" is a moving target as balances shrink at different rates, and re-sorting each month is what makes the strategy keep pointing at whichever debt is currently smallest, rather than whichever one happened to be smallest on day one.
func targetOrder(_ ids: [UUID]) -> [UUID] {
switch strategy {
case .avalanche:
return ids.sorted {
(rateByID[$0] ?? 0, balances[$1] ?? 0) > (rateByID[$1] ?? 0, balances[$0] ?? 0)
}
case .snowball:
return ids.sorted { (balances[$0] ?? 0) < (balances[$1] ?? 0) }
case .minimumOnly:
return ids
}
}
Avalanche's comparator is a tuple sort — rate first, and when two debts happen to carry the same rate, the tie breaks toward whichever currently has the smaller balance, so an APR tie doesn't leave the order arbitrary.
A budget that can't work says so, calmly
Nothing here needs a defensive iteration cap bolted on as an afterthought — maxMonths (100 years) and a didNotConverge flag on the result are part of the type from the start:
public struct PayoffSimulation {
public let monthsToDebtFree: Int
public let totalInterest: Decimal
public let debtFreeDate: Date
public let payoffOrder: [UUID]
/// True if the budget could not clear the debts within the 100-year cap
/// (usually because it is below the sum of minimum payments).
public let didNotConverge: Bool
}
The comparison screen just checks the flag: "Budget is below the sum of minimum payments," in plain text, in place of a chart that would otherwise never finish. No crash, no spinner that never resolves — a budget that can't retire the debt says so as a fact about the numbers, the same way savingsRate returns nil instead of dividing by zero.
Why the two totals end up close
For debts with similar rates, avalanche and snowball land on nearly the same total interest — the gap widens only when one debt's rate is far above the rest, since that's the one avalanche attacks immediately and snowball defers. Tally's payoff comparison screen shows total interest and months-to-debt-free for both, side by side, precisely because which one actually wins depends on a person's real numbers, not a rule of thumb.
Next
Debt payoff ends unambiguously — the balance hits zero. A goal is softer: a target amount, maybe a target date, and a running question of whether a stated plan is enough to get there. The next post covers how GoalMath answers that with far less machinery than debt payoff needed.