From 9c5bfb6e26264665b529f1ff552d5e148ccc0b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:11:50 +0000 Subject: [PATCH] Defaults a screen picks for you are picked from what you can see A second top-down pass on current main: typecheck, the full suite (2073), a production build started in demo mode and every reachable page crawled as each of the three demo members, checking every response for the names of another member's private accounts, envelopes and groups. No server errors, no dead links, no leaks. Every POST that takes an id from the form body goes through a visibility guard. The simulated household closes its identity in every month of every budget on 41 seeds, 39 of them new, with the rollup matching a cold compute, every transfer cancelling and no bank account overdrawn. What it did find was three places that read the whole household to pick something for the reader. None printed another member's private data, since each was filtered on the way out, but each chose from it: - Review's "File as" suggestion counted every member's filings. A payee Ravi files privately more often than Priya files it anywhere came out as his envelope, which the page would not name, so Priya got no suggestion. It now uses the reader's history, as payeeStats already does on /add. - /add started on the household's last-used account. When that was another member's private account it was not in the list, so nothing was selected. - /health showed the household's transaction count, which moved whenever somebody spent privately. Each now filters with hiddenTransactionSql. Three tests fail without it. 2076 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016zXWwzys3K4XksLh3rrcKu --- README.md | 2 +- src/app.ts | 29 ++++++- src/web/viewer-defaults.test.ts | 141 ++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/web/viewer-defaults.test.ts diff --git a/README.md b/README.md index b199dfd5..b60c3071 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ runtime dependencies. harvest, drawn from deliberately through the year. ``` -2073 tests · 0 runtime dependencies · 57 migrations · 209 routes +2076 tests · 0 runtime dependencies · 57 migrations · 209 routes ``` ![The budget screen: the month grid, Ready to Assign, credit-card payment envelopes, and the in-app digest](docs/screenshots/budget.png) diff --git a/src/app.ts b/src/app.ts index 98877975..c96fe8db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2770,9 +2770,15 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re */ const accounts = listAccounts(db, { viewerMemberId: viewer(ctx) }) .filter((a) => !DERIVED_VALUE_SUBTYPES.has(a.subtype)); + // The reader's last-used account. The household's could be somebody + // else's private one, which is not in the list, so nothing was preselected. + const lastUsedHidden = hiddenTransactionSql("t", viewer(ctx)); const lastUsed = queryOne<{ account_id: string }>( db, - `SELECT account_id FROM transactions WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 1`, + `SELECT t.account_id FROM transactions t + WHERE t.deleted_at IS NULL AND NOT ${lastUsedHidden.sql} + ORDER BY t.created_at DESC LIMIT 1`, + ...lastUsedHidden.params, ); return render( @@ -4484,6 +4490,13 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re * else, from the one place it is written down. */ const reviewHidden = hiddenTransactionSql("t", viewer(ctx)); + /* + * And the envelope a payee "usually" goes to is the reader's usual, as it + * is on /add. Counted over the household, a payee Ravi files privately + * more often than Priya files it anywhere came out as his envelope, which + * the page rightly would not name, so Priya got no suggestion at all. + */ + const usualHidden = hiddenTransactionSql("prev", viewer(ctx)); const reviewHiddenAccount = hiddenAccountSql("a", viewer(ctx)); const unfundedCards = [...view.categories.values()] @@ -4523,7 +4536,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re (SELECT prev.category_id FROM transactions prev WHERE prev.payee_id = t.payee_id AND prev.category_id IS NOT NULL - AND prev.deleted_at IS NULL + AND prev.deleted_at IS NULL AND NOT ${usualHidden.sql} GROUP BY prev.category_id ORDER BY COUNT(*) DESC, MAX(prev.date) DESC LIMIT 1) AS usual_category_id @@ -4534,7 +4547,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re AND t.transfer_pair_id IS NULL AND a.kind != 'tracking' AND t.amount < 0 AND NOT ${reviewHidden.sql} ORDER BY t.date DESC LIMIT ?`, - ...reviewHidden.params, UNCATEGORISED_PAGE, + ...usualHidden.params, ...reviewHidden.params, UNCATEGORISED_PAGE, ), uncategorisedTotal: queryOne<{ n: number }>( db, @@ -8457,6 +8470,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re const requestFailures24h = countRequestFailures(db); const recentFailures = requestFailures24h > 0 ? recentRequestFailures(db, 3) : []; const queue = reviewCount(db, viewerMemberId); + const visibleCount = hiddenTransactionSql("t", viewerMemberId); return [ { @@ -8507,7 +8521,14 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re { name: "Database", state: "healthy", - reason: `Connected. ${queryOne<{ n: number }>(db, `SELECT COUNT(*) AS n FROM transactions WHERE deleted_at IS NULL`)?.n ?? 0} transactions, ` + + // 15 · The transactions the reader can see: a household-wide count + // moved whenever somebody spent privately. + reason: `Connected. ${queryOne<{ n: number }>( + db, + `SELECT COUNT(*) AS n FROM transactions t + WHERE t.deleted_at IS NULL AND NOT ${visibleCount.sql}`, + ...visibleCount.params, + )?.n ?? 0} transactions, ` + `${queryOne<{ n: number }>(db, `SELECT COUNT(*) AS n FROM events`)?.n ?? 0} events.`, }, { diff --git a/src/web/viewer-defaults.test.ts b/src/web/viewer-defaults.test.ts new file mode 100644 index 00000000..cf9f1a10 --- /dev/null +++ b/src/web/viewer-defaults.test.ts @@ -0,0 +1,141 @@ +/** + * 15 · The small defaults a screen works out for you, worked out as you. + * + * Three places read the whole household to pick something for the reader: the + * envelope Review offers to file a payee as, the account /add starts on, and the + * transaction count on /health. None of them printed another member's private + * data — each was filtered on the way out — but each chose from it, so the + * reader got a worse answer than their own history gave: no suggestion, no + * preselected account, a count that moved when somebody else spent privately. + */ + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { freshDb, seedMember, startTestApp } from "./harness.test-data.ts"; +import type { Actor } from "../core/events.ts"; +import { rupees, type Paise } from "../core/money.ts"; +import { todayIST, addDays } from "../core/dates.ts"; +import { createAccount } from "../domain/accounts.ts"; +import { createGroup, createCategory } from "../domain/budget.ts"; +import { createTransaction } from "../domain/transactions.ts"; +import { ensurePersonalBudget } from "../domain/budgets.ts"; +import { execute, queryOne } from "../db/db.ts"; + +const RAVI = "m-ravi"; +const PRIYA = "m-priya"; +const ravi: Actor = { memberId: RAVI, source: "ui" }; +const priya: Actor = { memberId: PRIYA, source: "ui" }; + +function household() { + const db = freshDb(); + seedMember(db, RAVI, "Ravi"); + seedMember(db, PRIYA, "Priya"); + const joint = createAccount(db, ravi, { + name: "Joint current", kind: "budget", subtype: "savings", + openingDate: "2026-01-01", openingBalance: rupees(2_00_000), + }); + const wallet = createAccount(db, priya, { + name: "Priya's wallet", kind: "budget", subtype: "wallet", + openingDate: "2026-01-01", openingBalance: rupees(10_000), + }); + const everyday = createGroup(db, ravi, "Everyday"); + const eatingOut = createCategory(db, ravi, { groupId: everyday.id, name: "Eating out" }); + + const his = ensurePersonalBudget(db, RAVI, "Ravi"); + const hisAccount = createAccount(db, ravi, { + name: "Ravi private savings", kind: "budget", subtype: "savings", + openingDate: "2026-01-01", openingBalance: rupees(3_00_000), + holderMemberId: RAVI, visibility: "private", budgetId: his.id, + }); + const hisGroup = createGroup(db, ravi, "Mine", "normal", his.id); + const treats = createCategory(db, ravi, { groupId: hisGroup.id, name: "Ravi treats" }); + return { db, joint, wallet, eatingOut, hisAccount, treats }; +} + +describe("15 · defaults a screen picks for the reader are picked from what the reader sees", () => { + test("Review's 'File as' suggestion comes from the reader's own filings", async () => { + const { db, joint, eatingOut, hisAccount, treats } = household(); + const day = addDays(todayIST(), -10); + // Priya has filed Cafe Coffee Day to Eating out once. + createTransaction(db, priya, { + accountId: joint.id, amount: -rupees(200) as Paise, date: day, + categoryId: eatingOut.id, payeeName: "Cafe Coffee Day", ownerMemberId: PRIYA, + }); + // Ravi, privately, three times to his own envelope: the household-wide + // favourite, and one Priya cannot see. + for (let i = 0; i < 3; i++) { + createTransaction(db, ravi, { + accountId: hisAccount.id, amount: -rupees(250) as Paise, date: day, + categoryId: treats.id, payeeName: "Cafe Coffee Day", ownerMemberId: RAVI, + }); + } + // An unfiled one on the joint account waits in Priya's queue. + const unfiled = createTransaction(db, priya, { + accountId: joint.id, amount: -rupees(180) as Paise, date: todayIST(), + categoryId: eatingOut.id, payeeName: "Cafe Coffee Day", ownerMemberId: PRIYA, + }); + execute(db, `UPDATE transactions SET category_id = NULL WHERE id = ?`, unfiled.id); + + const app = await startTestApp(db, { memberId: PRIYA }); + try { + const body = await (await app.get("/review")).text(); + assert.ok(!body.includes("Ravi treats"), "Review named Ravi's private envelope"); + assert.match(body, /File as\s+Eating out/, "Priya's own history was ignored in favour of Ravi's"); + assert.deepEqual(app.failures, []); + } finally { + await app.close(); + } + }); + + test("/add starts on the reader's last-used account, not somebody else's private one", async () => { + const { db, wallet, hisAccount, treats, eatingOut } = household(); + createTransaction(db, priya, { + accountId: wallet.id, amount: -rupees(90) as Paise, date: todayIST(), + categoryId: eatingOut.id, payeeName: "Chai", ownerMemberId: PRIYA, + }); + // Entered after Priya's, so it is the household's most recent. Stamped a + // minute later so the order does not rest on two inserts in one millisecond. + const his = createTransaction(db, ravi, { + accountId: hisAccount.id, amount: -rupees(500) as Paise, date: todayIST(), + categoryId: treats.id, payeeName: "Bookshop", ownerMemberId: RAVI, + }); + execute(db, `UPDATE transactions SET created_at = '2999-01-01T00:00:00.000+05:30' WHERE id = ?`, his.id); + + const app = await startTestApp(db, { memberId: PRIYA }); + try { + const body = await (await app.get("/add")).text(); + assert.match( + body, new RegExp(`