What was at stake
The same numbers on every screen, and no euro lost or counted twice.
Finely shows money in many places: the month on Home, the timeline, Statistics, a widget, the on-device assistant, and Siri. A charge that appears twice, a total that differs between two screens, or a save that fails without a word would break trust faster than any missing feature.
My responsibility — the financial domain and its persistence. I designed how Finely stores money, writes it, and reads it back for every screen, widget, and Siri answer.
Store facts, compute the rest
Finely keeps two kinds of records in SwiftData, and nothing else:
- Transactions — something that happened, or a one-off planned ahead: an amount, a date, a label, a category, an account.
- Recurring rules — something that repeats: a price, a rhythm (every day, week, month, or year), and the date it started.
Everything else is computed from those two when a screen asks: the rent due next week, what is left this month, the balance of an account. Upcoming charges are never stored in the database, so they cannot go stale. Change a rule, and every screen sees the new future on its next read.
Preferences live outside the database on purpose. The iCloud setting decides how the database is opened, and a setting cannot live inside the thing it decides how to open.
One door in
Money enters Finely through the add form, Siri and Shortcuts, CSV import, and the recurring charges declared during onboarding. Each of them builds a draft, checks it, and hands it to one store: the only code that writes to the database.
The store saves a change whole or not at all. When a save fails, it undoes the change in memory and reloads what is really on disk, so what the screens show never drifts from what is stored.
insert the new rowstry to saveif saving fails: undo the change in memory reload what is on disk report that nothing was savedtell every screen that the ledger changedEvery write answers whether it landed. Siri, Shortcuts, and import rely on that answer: they only confirm a transaction the store actually kept.
Adding a recurring rule is the same kind of all-or-nothing step. A rule that started in the past brings its past charges with it, and the rule and its charges are saved together. An earlier version saved them separately and only checked the first save, so a failure on the second still reported success.
Large imports save in batches of a hundred rows and let the interface draw between batches. The first batch that fails stops the import: whatever made it fail would only fail again on the next thousand rows. The batches already saved stay, and the import says how many rows it kept.
Write the past once, compute the future
A recurring charge becomes a real transaction on the day it falls due. A catch-up pass does that when the app launches, at midnight, and when the app comes back to the foreground. However many times it runs, it must write each charge exactly once.
for each recurring rule: due dates = dates after the rule's mark, up to today, skipping days where the rule already has a charge for each due date: record a transaction with the rule's current price, label, and category move the mark to the last date coveredsave once, and only if something changedThe mark does the real work. It is stored with each rule and only moves forward. It cannot be worked out from the transactions themselves, because a rule’s schedule can change under them. Switch a monthly rule to weekly, and its weeks are counted from its first date: a pass that looked for days without a charge would find every past week empty and bill each one.
Each charge copies the price, label, category, and kind at the moment it is written. Raising a subscription’s price changes the rule, so future charges follow, while the charges already billed keep what was actually paid. Editing this month’s charge updates the rule from then on. A rule’s charges from earlier months cannot be edited, and they cannot be deleted one at a time: you either stop the rule and keep its history, or delete it with every charge it made.
One merge for every screen
A screen never mixes stored and upcoming money by itself. One function builds the list for any window of time: the transactions recorded in it, plus the dates each rule will bill after its mark, minus days where that rule already has a charge, newest first.
the list between start and end = recorded transactions dated in the window plus, for each recurring rule: its due dates in the window that fall after its mark, except days it already chargedsort newest first, in the same order every timeEvery entry has the same shape, whether it happened or is still to come. Each one also keeps the same identity every time the list is rebuilt, so the timeline only animates the rows that really changed.
Home, the timeline, Statistics, the on-device assistant, and Siri all read that list. The widget runs in another process and never opens the database: the app hands it a small snapshot built by the same code as Home, so the widget never works out a figure of its own. The merge takes plain lists and needs no database, so tests can call it directly.
Exact money and dates
- Money stays an exact decimal number from input to total. Floating point, which cannot store most cents exactly, appears only in percentages and chart shapes, and never flows back into an amount. Siri hands amounts over as a floating-point number, so they are rounded to two decimals as text before they become money.
- The kind is stored, not guessed from the sign. Each record says whether it is income, an expense, or a subscription. Every way in sets the kind and the sign together, so a subscription is always money out, and income and spending totals sort rows by kind, never by sign.
- Windows include their first day and exclude the next period’s first day, so a charge on the 1st belongs to exactly one month.
- The calendar is never frozen. Date math always reads the phone’s current calendar, never a copy kept from launch. Recurring charges are compared by calendar day, not by the second, so a daylight-saving change cannot move one.
Recurring dates are always counted from the rule’s first date, in one step, rather than from the last charge. A rule on the 31st bills January 31, February 28, and March 31, not March 28.
charge number n = first date + n periodsif that month is too short: use its last dayTest the rules, not the screens
Tests run against an in-memory store with iCloud turned off, and build their dates at noon so midnight and daylight-saving changes cannot make them flaky. They pin the promises above: the catch-up pass can run twice without writing twice, the mark never moves backwards, a charge keeps the price it was billed at, the month lookup agrees with a plain filter on every window, and the widget never disagrees with Home about the running month.
Trade-offs
- A store on the main thread. One database context on the main actor keeps writes simple and ordered. Lookups are binary searches over sorted in-memory lists, and large imports yield between batches. Heavier work could move off the main thread later: the value types it would need already exist.
- Shaped by iCloud sync. CloudKit forbids unique constraints and needs every attribute to be optional or to have a default, so identity is enforced in code and every field has a default.
- No soft delete. Deleting is final; stopping a rule is how its history is kept.
- The stored shape only grows. New fields must be optional or have a default, removing a category would need a written migration, and a field sent to iCloud can never be removed.
History
- Published