The app is called *Canny*Cart, and for its first few weeks it had no idea what anything cost. The shopping tab could capture a list by voice, work offline in the aisle, and remember your usual milk — but the money stayed in your head, which is exactly where money is worst at staying.
The obvious fix is an Expense model: a transactions table, categories, maybe a sync job keeping it consistent with the list. The shipped fix is one line of schema — because the expense rows already existed. They're the shopping items.
TL;DR — In a shopping app, an "expense" is an attribute of an item you already have, not a new noun. Fix the price semantics first (unit price × quantity), sum in integer pennies, make price entry ride the existing offline mutation so it works with no signal, color the budget bar with semantic tokens (never the brand accent), and show "N unpriced" next to the total instead of silently treating unpriced as £0. The whole tab: one new field, 15 files, +545 lines.
(Part 13 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The fork: an Expense model vs an attribute
Part 11 had a rule I keep re-using: before adding a model, check whether the new thing is really a new kind or just a new fact about an existing kind. A grocery expense fails the new-kind test hard. It has the same owner, the same lifecycle, the same aisle, the same name as the shopping item it describes — a parallel Expense table would spend its whole life being synced against the list it mirrors.
So: ShoppingItem.price (a field that already existed, waiting for semantics) plus exactly one new line —
ShoppingList: a.model({
// …
isTemplate: a.boolean(),
budget: a.float(), // per-list spend budget (GBP) — Expenses tab
items: a.hasMany("ShoppingItem", "listId"),
Additive field → no table replacement, no migration. The whole commit: 15 files, +545/−1, one schema line. (A Receipt model did arrive later — when photographed receipts introduced a genuinely new kind: an immutable record of a transaction. That's the next post. The tab didn't need it to ship.)
Decide the price semantics before the UI
The plan doc locked this before any code: price is the unit price, and the line total is price × (quantity ?? 1). Because that's what shelf labels say — milk is "£1.30", not "£2.60 if you buy two" — and it means changing a quantity moves the money without touching the price. Weighed items fall out of the same rule loosely: quantity 0.5, unit kg, price per kg.
It's a two-minute decision on paper and a miserable retrofit once prices are stored ambiguously. Cheap decisions early; expensive ones never.
Sum in pennies
Floating-point money drifts — 0.1 + 0.2 territory, multiplied across a 30-item list. The helpers sum in whole pennies and format at the edge:
// GBP money helpers. Sum in whole pennies to avoid float drift, format at the edge.
export const toPennies = (n: number) => Math.round(n * 100);
/** Line total in pennies: unit price × quantity, rounded to the penny. */
export const linePennies = (price: number, quantity?: number | null) =>
Math.round(toPennies(price) * (quantity ?? 1));
export const formatMoney = (n: number) => `£${(n ?? 0).toFixed(2)}`;
Nine lines. The number of production money bugs those nine lines prevent is not nine.
Capture in both places, one mutation — and offline for free
Prices go in wherever you happen to be: a compact + £ affordance on the shopping row (price as you shop), or the editable list in the Expenses tab. Both open the same 122-line AmountSheet (a numeric £ entry that budget editing reuses too), and both end in the same call: updateItem({ price }).
That one sentence is the payoff of Part 7's offline architecture. updateItem is a keyed offline mutation default — optimistic, persisted, replayable after an app kill — so price entry works in a concrete supermarket basement with zero bars, with zero new plumbing. Adding the feature was adding price to the mutation's variables type. Setting a budget joined the same system as one more keyed default:
queryClient.setMutationDefaults(MK.setBudget, {
mutationFn: async ({ listId, budget }: SetBudgetVars) => {
const { data } = await client.models.ShoppingList.update({ id: listId, budget });
return data;
},
onMutate: async ({ listId, budget }) => {
// optimistic patch of the cached lists; rollback + invalidate as usual
},
});
Architecture you paid for once, paying you back per feature. This is the first feature where that stopped being a theory.
The tab is a pure selector over the cache
There's no fetch in the Expenses tab. useExpenses computes everything from the items React Query already holds:
const priced = items.filter((i) => typeof i.price === "number");
const totalPennies = priced.reduce(
(s, i) => s + linePennies(i.price as number, i.quantity),
0,
);
const pct = budget && budget > 0 ? total / budget : 0;
const status: BudgetStatus =
budget == null ? "none" : pct > 1 ? "over" : pct >= 0.8 ? "near" : "ok";
const map = new Map<string, number>(); // pennies per category
for (const i of priced) {
const c = i.category?.trim() || "Other";
map.set(c, (map.get(c) ?? 0) + linePennies(i.price as number, i.quantity));
}
Pure function of cached data ⇒ the whole tab works offline by construction. The by-aisle breakdown then renders in the user's own saved aisle order — the ordering system from Part 9 getting reused instead of reinvented.
Semantic colors, and the accent that isn't there
The budget bar is green under 80%, amber 80–100%, red over — the design system's semantic trio. What it never uses is coral, the brand's money-moment accent. That's deliberate discipline, not oversight: in this design system red means over budget, green means under/cheapest, and coral is reserved exclusively for savings — a feature this post doesn't build. An accent you spend everywhere is an accent you no longer have. (Coral gets its moment in a later post, and it's worth the wait.)
The honesty note
Next to the total: "3 items without a price." Unpriced items are excluded from the sum and counted out loud — never silently treated as £0, which would make the total confidently wrong. This became a small recurring principle across the app: a number you can't trust is worse than no number. The total earns trust by admitting what it doesn't know.
Then the tab got a haircut
Two follow-up commits taught the layout lesson. First the summary was compacted to a constant height: spend-by-aisle shows the top 4 (the list is already sorted by spend), the price-me section lists only unpriced items capped at 4, and an "everything's priced" state replaces the section instead of leaving a hole. Then the full, growing list moved to its own screen — /expenses/items, with filter chips and a debounced search (this commit is where the shared useDebouncedValue hook was born; every search input since uses it).
The rule that fell out: a tab is a summary; a list is a screen. Whatever accumulates length needs a dedicated scrolling surface — a dashboard that grows with your data slowly stops being a dashboard.
Honest limits — each one is a future post
-
GBP is hardcoded.
money.tsliterally templates a£. Fine for a single-region MVP; it fell soon after, and the audit of everywhere a currency symbol hides is its own story. - The budget is per-list. "This shop: £30" — but months are how people actually budget, and moving budgets from lists onto months later became a genuinely interesting data-model rework.
- No receipts. Prices go in by hand. The camera was about to change that.
This is the post where the money arc opens; those three limits are the arc.
What I took away
- Check the new-kind test before adding a model. An expense here is a fact about a shopping item — one field beats a parallel table you'll sync forever.
- Fix semantics before UI. Unit price × quantity was a plan-doc decision; retrofitting ambiguous stored prices would have been a migration.
- Integer pennies, format at the edge. Nine lines against a whole class of drift bugs.
- Good architecture pays per feature. Offline price capture cost nothing because Part 7 already built the rails.
- Numbers must confess. Count the unpriced; never silently assume £0.
Next up
The by-hand part is the weak link — after a real shop, nobody prices 30 items manually. Next post: point the camera at the paper receipt and let Claude read it — the vision Lambda, the review sheet that matches receipt lines to list items, and the day a receipt saved silently failed.
Where do you draw the line between "attribute on an existing model" and "new table" for money data? I keep getting away with the attribute — tell me when it finally bites.


Top comments (0)