Finance Core
Projecting Recurring Cash Flow
Subscriptions, income sources, and (indirectly) debt due dates all repeat on a schedule, and none of them are stored as a row per future occurrence — a subscription is one record with a billing day and a cadence; every renewal date, past or future, is computed from it on demand. That type is Cadence, and getting its date math right is the least glamorous, most bug-prone part of the whole engine.
Six cadences, one normalization
public enum Cadence: String, Codable, CaseIterable {
case weekly, biweekly, monthly, quarterly, semiannual, annual
/// Average occurrences per year — used only to normalise an amount to a
/// monthly-equivalent figure, never for scheduling actual dates.
public var occurrencesPerYear: Decimal {
switch self {
case .weekly: return 52
case .biweekly: return 26
case .monthly: return 12
case .quarterly: return 4
case .semiannual: return 2
case .annual: return 1
}
}
public func monthlyEquivalent(of amount: Decimal) -> Decimal {
Money.rounded(amount * occurrencesPerYear / 12)
}
}
This is what let the previous post add a weekly paycheck, a quarterly insurance premium, and an annual domain renewal into one comparable monthly figure — occurrencesPerYear exists purely for that normalization, and the doc comment is explicit that it must never be used to compute an actual billing date. A year isn't always 52 exact weeks or 12 exact months from any given anchor, so treating "52 times a year" as "every 7 days, 52 times" for scheduling would drift out of sync with the real billing cycle almost immediately.
What "add a month" actually does
Scheduling real dates needs real date arithmetic, and Calendar's behavior around month-end anchors is the one place this gets genuinely subtle — subtle enough that it's worth stating precisely, because the common assumption about it is wrong. Adding a month to January 31st does not overflow into March; Calendar clamps it to February 28th (or 29th). That's tested directly:
@Test("Next monthly occurrence after a mid-month date, anchored on the 31st")
func nextOccurrenceMonthly() {
let anchor = DateComponents(calendar: cal, year: 2025, month: 1, day: 31).date!
let asOf = DateComponents(calendar: cal, year: 2026, month: 2, day: 15).date!
// Feb has no 31st, so the occurrence clamps to Feb 28 2026 — still after asOf.
let next = Cadence.monthly.nextOccurrence(after: asOf, anchor: anchor, calendar: cal)
// next == Feb 28, 2026
}
Clamping sounds like the safe, correct behavior — and for a single addition, it is. The actual bug shows up one step later, if that clamped result becomes the base for the next addition: February 28th plus one month is March 28th, not March 31st, because Calendar has no memory of what day the anchor was supposed to be — it only sees the 28 it was handed. Iterate that forward and an anchor on the 31st is permanently dragged down to 28 after the first February it crosses, even in every 31-day month that comes after.
The fix: multiply from the anchor, never iterate from the last result
The whole bug only exists if you chain additions. Cadence avoids it by computing every occurrence as a single offset, multiplied by how many cycles out it is, applied once to the original anchor:
/// The date components for the Nth occurrence measured *from the anchor*.
///
/// Multiplying from the anchor (rather than iterating one step at a time)
/// matters for month-based cadences: an anchor on the 31st still lands on the
/// 31st in long months instead of being permanently dragged back to the 28th
/// after the first February.
private func offset(occurrences n: Int) -> DateComponents {
switch self {
case .weekly: return DateComponents(day: 7 * n)
case .biweekly: return DateComponents(day: 14 * n)
case .monthly: return DateComponents(month: n)
case .quarterly: return DateComponents(month: 3 * n)
case .semiannual: return DateComponents(month: 6 * n)
case .annual: return DateComponents(year: n)
}
}
private func date(occurrence n: Int, anchor: Date, calendar: Calendar) -> Date {
calendar.date(byAdding: offset(occurrences: n), to: anchor) ?? anchor
}
Occurrence 2 is anchor + 2 months, computed directly — never (anchor + 1 month) + 1 month. Each occurrence starts fresh from the one date that actually carries the intended day, so Calendar's clamp only ever has to consider "does this specific target month have day 31," never "what day did last month's clamp leave me on." The proof this actually recovers correctly is also a test:
@Test("Month anchoring on the 31st is preserved in long months")
func day31PreservedInLongMonths() {
let anchor = DateComponents(calendar: cal, year: 2025, month: 1, day: 31).date!
// Asking after Feb 2026 should roll to Mar 31 2026, not Mar 28.
let asOf = DateComponents(calendar: cal, year: 2026, month: 3, day: 1).date!
let next = Cadence.monthly.nextOccurrence(after: asOf, anchor: anchor, calendar: cal)
// next == Mar 31, 2026 — recovered, not stuck at 28
}
Three questions, three functions
Everything that actually needs a date asks one of three questions, and Cadence answers each with its own function rather than one general-purpose "give me occurrences" call bent to fit every caller:
nextOccurrence(after:anchor:)— "when does this renew next," for a subscription's next-renewal date.mostRecentOccurrence(onOrBefore:anchor:)— paired withnextOccurrence, it brackets the current billing period as[start, end), which is exactly how the app checks whether a charge for this period has already been logged.occurrences(from:to:anchor:)— every occurrence in a window, for painting a month of dots on the Calendar screen.
All three are built on the same anchor-multiplied date(occurrence:anchor:calendar:) underneath, so the month-end fix only had to be written once.
A billing period is how duplicate logging actually gets prevented
When a subscription's charge gets logged as a real transaction, the check for "have I already logged this one" isn't a fuzzy "within a few days" heuristic — it's exact, using the billing period the two bracketing functions above define:
static func billingPeriod(for sub: Subscription, containing date: Date, calendar: Calendar) -> (start: Date, end: Date) {
let start = sub.cadence.mostRecentOccurrence(onOrBefore: date, anchor: sub.anchorDate, calendar: calendar)
let end = sub.cadence.nextOccurrence(after: start, anchor: sub.anchorDate, calendar: calendar)
return (start, end)
}
static func isPeriodLogged(for sub: Subscription, on date: Date, context: ModelContext, calendar: Calendar) -> Bool {
let period = billingPeriod(for: sub, containing: date, calendar: calendar)
return charges(for: sub, context: context).contains { $0.date >= period.start && $0.date < period.end }
}
Because the period boundaries come from the same clamp-and-recover-correct Cadence math, a subscription anchored on the 31st gets a real, correctly-bounded billing period even in February — not an off-by-a-few-days window that happens to usually work.
A debt's due date doesn't need any of this
A debt's payment is always monthly, so it gets a much smaller sibling utility instead of the full Cadence machinery — just a clamp, with nothing to iterate or recover from:
public enum MonthlyDueDate {
/// Clamps `dayOfMonth` to the number of days actually in `month`'s
/// calendar month (e.g. day 31 -> Feb 28/29), not a fixed cap.
public static func date(dayOfMonth: Int, in month: Date, calendar: Calendar = .current) -> Date {
let range = calendar.range(of: .day, in: .month, for: month) ?? 1..<29
let lastDay = range.upperBound - 1
let clamped = min(max(dayOfMonth, range.lowerBound), lastDay)
return calendar.date(bySetting: .day, value: clamped, of: month) ?? month
}
}
There's no anchor to drift away from here, because there's nothing to iterate — every call clamps dayOfMonth fresh against whatever month it's asked about. Two utilities exist because two real shapes of "which day" exist: a subscription's whole billing history projected from one anchor, and a debt's due date recomputed independently for each month it's asked about.
Next
Subscriptions and income sources repeat independently, which is what made a single Cadence type possible. Debt doesn't work that way — this month's minimum payment on a credit card depends on last month's balance, which depended on the month before. The next post covers the two different tools Tally uses for that: a fixed-rate amortization schedule for one loan at a time, and a month-by-month simulator for paying down several debts against a shared budget.