← Finance Core

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:

Swift
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:

Swift
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:

Swift
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.

Swift
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:

Swift
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.