Receipt scanning worked, and it felt like homework. You'd come home with the receipt, open the Expenses tab, scan — and the app would immediately want a decision: review every line, confirm every match against the list, log it. If you were unpacking shopping with one hand, the honest answer was "not now" — and "not now" meant the receipt never got scanned at all.
The fix inverted the order of operations: save the receipt first, ask questions later. It sounds like a UI tweak. It turned into a small data-model design with one immutability rule to protect.
TL;DR — A receipt button on the Shopping tab saves the receipt before asking "Link now / Later."
Receipt.reconciledAt === nullmeans pending, which drives a strip and badge on the list. Lines are stored with provisional match ids andaddedToList: false, so an unlinked receipt's money shows as the Expenses off-list caption, never the headline. Linking re-verifies every match against the list as it is *then* (a hand-ticked item degrades to "still to buy," never a double tick), applies ticks with the receipt's printed date, enriches the snapshot once, and stampsreconciledAt. Matching became model-first with a conservative fallback ladder — and deliberately no edit distance.
(Part 29 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The order of operations was the bug
The original flow coupled two jobs that don't want to be coupled: recording the receipt (a fact about the world — you bought these things for this much) and reconciling it with the shopping list (ticking items, applying prices, adding the strays). Recording takes a photo. Reconciling takes attention. Forcing them into one sitting made the cheap one hostage to the expensive one.
So the Shopping tab grew a receipt button (next to History): photograph → parsed → saved immediately → then a two-button question: Link now or Later. Later is a real answer. The receipt exists either way.
reconciledAt: null is a state, not an absence
The whole feature hangs on one nullable timestamp. A receipt with reconciledAt === null is pending: the list shows a strip ("1 receipt waiting to be linked") and a badge on the receipt button. Linking stamps the timestamp. Dismissing the strip stamps it too — with no links — so "I'll never link this one" is also a first-class outcome rather than a permanent nag.
Receipts scanned from the Expenses tab are born linked: the review sheet's primary button records and reconciles in one go, exactly as before. Nothing changed for the people who liked the old flow; a second, cheaper door opened for everyone else.
Provisional matches, and money that stays out of the headline
A pending receipt's lines are stored with provisional match ids (the parser's best guess at which list item each line corresponds to) and addedToList: false on every line. That second flag is what keeps the money honest. Part 19 established the rule: the Expenses headline sums the purchase ledger — items actually bought through a list — and receipt lines that never touched a list are a separate caption. An unlinked receipt is precisely that: real spending, not yet attributed to any list or budget. So its money shows in the caption and never in the headline, until linking moves the lines into the ledger. No double counting is possible, because a line is either in the ledger (linked) or in the caption (pending) — never both.
Linking re-verifies against the list as it is then
Here's the part that makes "later" safe. Time passes between scan and link. The list changes: you tick things by hand, delete something, add more. A provisional match made at scan time might point at an item that's since been ticked, removed, or claimed by another line.
So the link page trusts nothing provisional. It re-verifies every match against the list as it exists at link time: the item must exist, be unchecked, and be unclaimed by any other line. The important degradation rule: an item you've already ticked by hand degrades to "still to buy" for that line — it is never double-ticked. A receipt arriving late must not rewrite what your hands already did.
Then the ticks are applied with the receipt's printed purchase date (the same midday-local stamping as Part 18's ledger), so a receipt linked on Friday for a shop on Tuesday lands in Tuesday's week.
The one-time enrichment, and the immutability rule
Part 14 made a promise: the receipt's line snapshot is immutable — a record of what was read, independent of the live items that keep changing. Linking looks like it violates that: it writes match ids and addedToList back into the lines.
The resolution is a rule with a boundary: enrichment happens exactly once, at the moment reconciledAt is stamped, and never again. Before linking, the snapshot is "what the parser read plus provisional guesses"; after linking, it's "what the parser read plus what actually happened." The record graduates from provisional to final one time and then freezes. A snapshot that can be enriched once, at a known transition, is still a snapshot; one that can be edited whenever is a mutable table wearing a costume.
Matching: model-first, fallback ladder, no edit distance
The reconciliation also changed how lines find their items. The parse mutation now takes the list's unchecked {id, name} candidates as an optional argument and returns a matchId per line — told, as always, to prefer null over a guess. The client re-verifies every id (exists, unchecked, unclaimed), then falls back to a conservative ladder for the rest: barcode → exact normalised name → token containment. Deliberately no edit-distance matching: fuzzy string similarity is how "Cream" matches "Ice cream" and a wrong tick lands on a real purchase. A conservative matcher that leaves a line unmatched costs one tap; a clever one that mismatches costs trust.
And a UX lesson paid for in full: the first version let you correct a match from a picker sheet stacked on top of the review sheet. A picker over a sheet broke the flow — two modal layers, two dismiss gestures, and confusion about which one you were in. Matches now render as read-only text in the review sheet, and all corrections live on the link page, which became the one correction surface: three entry points, one-to-one claims, and picking a new item visibly un-claims the previous one. One place to fix things beats a fix-it affordance on every surface.
The link page itself moved into a shared component mounted as thin routes in both the Shopping and Expenses stacks — the Part 14 pattern again — so opening it from Expenses no longer jumps you to a different tab.
What I took away
- Decouple recording from reconciling. The photo is cheap; the decisions are expensive. Never make the cheap step wait for the expensive one.
-
A nullable timestamp is a state machine.
reconciledAt: nulldrove a strip, a badge, a caption rule and a dismissal path — one field. -
Provisional data must stay out of totals until it's verified — the
addedToListflag is the honesty gate. - Re-verify against the present. Anything decided at scan time is a guess by link time; your hands' work wins.
- Enrich once, then freeze. Immutability with a single known transition is still immutability.
- No edit distance in money matching. Conservative matchers cost taps; fuzzy ones cost trust.
- One correction surface. A picker over a sheet was two too many layers.
Next up
Part 30 is the one this whole series has been building toward: shipping to both stores — the runbook, the credentials, the privacy declarations, the review rejection, and what v1.0.1 through v1.0.4 taught about releasing as one person.
Where in your app does a cheap action get held hostage by an expensive decision — and what would "save first, ask later" look like there?
Top comments (0)