Finance Core
One Calendar, Four Sources of Truth
The Calendar screen isn't a ledger of what already happened — it's a forward-looking month of what's coming: subscription renewals, debt due dates, goal deadlines, and recurring income deposits. Four independent sources, none of which know the other three exist, merged onto one grid.
A common shape, four different origins
private enum EventKind: String { case subscription, debt, goal, income }
private struct CalendarEvent: Identifiable {
let id: String
let kind: EventKind
let date: Date
let name: String
let amount: Decimal?
var subscription: Subscription?
var debt: Debt?
var isLoggable: Bool { subscription != nil || debt != nil }
}
Only two of the four kinds are isLoggable — a subscription renewal or a debt payment can be tapped to record that it happened, right from the day it's due. A goal deadline and an income deposit are informational only; there's nothing to "log" about a target date arriving.
Two cadences and a clamp, not one shared engine
Subscriptions and income sources both repeat on a Cadence, so both use the same windowed lookup from the previous post. A debt doesn't have a cadence at all — it has one fixed day of the month, clamped fresh for whichever month is on screen:
for sub in subscriptions where sub.isActive {
for date in sub.cadence.occurrences(from: start, to: end, anchor: sub.anchorDate, calendar: calendar) {
add(date, CalendarEvent(kind: .subscription, date: date, name: sub.name, amount: sub.amount, subscription: sub))
}
}
for debt in debts where debt.currentBalance > 0 {
let due = MonthlyDueDate.date(dayOfMonth: debt.paymentDayOfMonth, in: start, calendar: calendar)
add(due, CalendarEvent(kind: .debt, date: due, name: debt.name, amount: debt.minimumPayment, debt: debt))
}
for goal in goals {
if let target = goal.targetDate, target >= start, target < end {
add(target, CalendarEvent(kind: .goal, date: target, name: goal.name, amount: nil))
}
}
for source in incomeSources where source.isActive {
guard let anchor = source.anchorDate else { continue }
for date in source.cadence.occurrences(from: start, to: end, anchor: anchor, calendar: calendar) {
add(date, CalendarEvent(kind: .income, date: date, name: source.name, amount: source.amount))
}
}
A debt that's already paid off (currentBalance > 0 fails) drops out of the calendar entirely rather than showing a $0 due date forever. And an income source's anchorDate is optional — sources created before that field existed simply have nothing to project from yet, so they're silently skipped rather than crashing on a date that was never set. Real data accumulates that kind of gap; the loop just has to not choke on it.
De-duplication isn't filtering — it's a checkmark
The natural assumption is that a subscription due date "already paid" should disappear or get suppressed from the calendar. It doesn't. The dot for a renewal shows up on its date every time, logged or not — what changes is only whether tapping it offers to log a new charge or shows it's already done:
private func isLogged(_ event: CalendarEvent) -> Bool {
if let sub = event.subscription {
return SubscriptionService.isPeriodLogged(for: sub, on: event.date, context: context, calendar: calendar)
}
if event.debt != nil {
return loggedDebtEventIDs.contains(event.id)
}
return false
}
That single function hides a real asymmetry between the two loggable kinds. A subscription's logged state is a genuine, persisted fact — it asks whether a transaction already exists inside the exact billing period the previous post's Cadence math bounds. A debt's logged state is loggedDebtEventIDs: a plain Set<String> held in the view's own state, cleared the moment the screen is dismissed. The comment in the real code says exactly why: DebtService.logPayment has no built-in duplicate guard the way SubscriptionService.isPeriodLogged does, so the session-local set is there purely to stop a second tap in the same sitting from double-logging the same payment — not to remember, days later, that it was already paid. Leave the screen and come back, and a debt payment already logged this month will show as unlogged again, ready to be tapped a second time.
That's a real, acknowledged rough edge, not a hidden one — the fix would be giving debt payments the same period-bounded existence check subscriptions already have. It just hasn't been built yet, and the calendar is honest about which of its two loggable event kinds it actually remembers.
Next
Every calculator in this series feeds a small number by itself — a rate, a date, a schedule. The dashboard has to turn all of them into one glance: a single health rating, a handful of trend arrows, and a short list of things actually worth flagging. The last post covers how that assembly works, and why the "previous period" figure for a trend comes from a stored record of the past, not a recomputation of it.