# The Ledger Behind Finely

> How Finely stores only transactions and recurring rules, writes each charge exactly once, and gives every screen the same list of past and upcoming money.

- Context: Finely
- Published: 2026-09-29
- Reading time: 8 min
- Canonical: https://lanfranchistephane.dev/articles/transaction-ledger/

## System at a glance

1. Input — Form, Siri, import, setup
2. One door — Saved whole, or rolled back
3. Stored facts — Transactions, recurring rules
4. One merge — Past and upcoming in one list
5. Every screen — Home, Stats, widget, Siri

## At a glance

- Role: Sole engineer on Finely, responsible for its financial domain, its persistence, and every screen that reads it.
- Challenge: Keep one set of numbers on every screen, widget, and Siri answer while recurring charges, imports, and edits keep changing the ledger.
- Outcome: Two kinds of stored records, one write path that saves whole or not at all, charges written exactly once, and one merge that every screen reads.

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

<picture>
  <source media="(max-width: 720px)" srcset="https://lanfranchistephane.dev/articles/transaction-ledger/stored-computed-narrow.svg">
  <img src="https://lanfranchistephane.dev/articles/transaction-ledger/stored-computed.svg" width="960" height="372" loading="lazy" decoding="async" alt="Finely stores only transactions, what happened, and recurring rules, what repeats. Upcoming charges, month totals, and account balances are computed from them on every read and never saved. The only copy is a small snapshot written for the widget, which cannot open the database.">
</picture>

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.

```pseudo title="A save lands whole, or not at all"
insert the new rows
try to save
if saving fails:
  undo the change in memory
  reload what is on disk
  report that nothing was saved
tell every screen that the ledger changed
```

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

```pseudo title="Write what fell due, 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 covered
save once, and only if something changed
```

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

<picture>
  <source media="(max-width: 720px)" srcset="https://lanfranchistephane.dev/articles/transaction-ledger/high-water-mark-narrow.svg">
  <img src="https://lanfranchistephane.dev/articles/transaction-ledger/high-water-mark.svg" width="960" height="412" loading="lazy" decoding="async" alt="A monthly rule billed in March, April, May, and June is switched to weekly in June, and its weeks are counted from its first date. Without a stored mark, the catch-up pass finds every past week without a charge and bills each one by mistake. With a mark stored after the June charge, everything before it is settled, and the weekly charges start after the mark.">
</picture>

:::note
The first plan for the redesign dropped the stored mark and asked instead whether a rule already had a charge on a given day. Building it proved that wrong: only the mark remembers how far billing has gone.
:::

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.

```pseudo title="One list for any window of time"
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 charged
sort newest first, in the same order every time
```

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

<picture>
  <source media="(max-width: 720px)" srcset="https://lanfranchistephane.dev/articles/transaction-ledger/one-merge-narrow.svg">
  <img src="https://lanfranchistephane.dev/articles/transaction-ledger/one-merge.svg" width="960" height="340" loading="lazy" decoding="async" alt="For any window of time, one function takes the recorded transactions in it and the charges the recurring rules will bill after the mark, skips days a rule has already charged, and sorts the result newest first. Home, the timeline, Statistics, the assistant, and Siri and Shortcuts read that list; the widget receives a snapshot built from it.">
</picture>

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.

```pseudo title="Count from the first date, never from the last charge"
charge number n = first date + n periods
if that month is too short:
  use its last day
```

## Test 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.

## Related

- [Adding Transactions to Finely from Siri and Shortcuts](https://lanfranchistephane.dev/articles/app-intents/) — Finely, 5 min read. How Finely turns a spoken sentence or an Apple Pay payment into a transaction: it never guesses the amount, reads a spoken transaction back before saving it, and only says it was added when the save really worked.
- [An On-Device Assistant That Never Makes Up a Number](https://lanfranchistephane.dev/articles/on-device-ai-assistant/) — Finely, 7 min read. How Finely answers questions about your money on the iPhone: Apple's on-device model only works out what is being asked, and Finely calculates every amount from your own transactions. Without Apple Intelligence, simple rules take the model's place.
