From 95d854475289952c28a1552935810b02b9968e2b Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 15 Aug 2026 16:17:38 +1000 Subject: [PATCH] transactions: change who paid, on a row and on a statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner was write-once for every ingestion path — a pantry receipt hardcodes DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ — so a shop the other person paid for was permanently filed as yours. PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A statement row returns 400 statement_owned and points at the statements page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing it there would either no-op or detach one row from the account it came from. PATCH /api/statements/[id] is new. The statements page has had an owner dropdown since it was built, wired to a route with no PATCH handler — every change 405'd, and because useUpdateStatement never checked res.ok it failed silently and the select snapped back on refetch. It writes both tables: 2,194 statement rows carry their own owner_id against 1,803 that inherit, so updating `statements` alone moves less than half and splits one account's history between two people. The guard is the point. Access is "owner OR holds a split", so handing a row over while holding no split removes it from your list and 404s every route that could put it back — only the new owner can undo it. That is 409 would_lose_access, and the modal offers both ways forward: add my split first, or give it away anyway. Taking a row onto your own ledger is never blocked, and claiming a row you cannot see is a 404 before any owner logic runs. Splits are deliberately not rewritten. They record shares, not direction, so a 50/50 flips from "they owe me" to "I owe them" untouched, settled included. Also adds the missing res.ok check to useUpdateTransaction, without which every rejection resolved as success: the modal closed, the list refetched, and the edit silently vanished. 14 new integration tests; 203 integration + 130 unit green. --- CLAUDE.md | 53 ++++ .../integration/transaction-owner.test.ts | 270 ++++++++++++++++++ src/app/api/statements/[id]/route.ts | 57 ++++ src/app/api/transactions/[id]/route.ts | 74 ++++- src/app/statements/page.tsx | 21 +- src/components/edit-transaction-modal.tsx | 95 +++++- src/lib/hooks.ts | 22 +- 7 files changed, 582 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/integration/transaction-owner.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 47c9bc3..4249332 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -804,6 +804,55 @@ is the drill-down for these totals. pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the cutover moves. +### Changing who paid (2026-08-15) + +Owner was **write-once for every ingestion path** until now — a pantry receipt +hardcodes `DEFAULT_OWNER_ID` (`receipt-ingestion.ts`), and there was not one +`UPDATE ... SET owner_id` in `src/`. A shop the other person paid for was +permanently filed as yours. + +Two routes, because the owner lives in two places: + +- **`PATCH /api/transactions/[id]` with `owner_id`** — manual rows only + (`statement_id IS NULL`). A statement row returns 400 `statement_owned` and + points at the statements page; its effective owner is + `COALESCE(t.owner_id, s.owner_id)`, so writing it here would either no-op or + detach one row from the account it was extracted from. +- **`PATCH /api/statements/[id]` with `owner_id`** — the statements page has had + this dropdown since it was built, wired to a route with **no PATCH handler**. + Every change 405'd, and `useUpdateStatement` never checked `res.ok`, so it + failed silently and the select just snapped back. + +**The statement route writes both tables.** 2,194 statement rows carry their own +`owner_id` against 1,803 that inherit, so updating `statements` alone moves less +than half and splits one account's history between two people. It updates rows +matching the *old* owner and returns `rows_moved`; all 2,194 agree today, and a +row that disagrees was set deliberately and is left alone. + +**Reassignment is a one-way door, and the guard is the point.** Access is +`owner OR holds a split` (`canAccessTransactions`), so handing a row over while +holding no split removes it from your list and 404s every route that could put +it back — only the new owner can undo it. The route returns **409 +`would_lose_access`** and the modal offers the two real ways forward: add your +split first, or "Give it away anyway" (`release: true`). Taking a row *onto* +your ledger is never blocked — there is no door to close behind you. + +Reassignment is a correction to a row you already hold, never a way to reach one +you do not: `canAccessTransactions` runs first, so claiming a stranger's +transaction is a 404 before any owner logic runs. + +**Splits are deliberately not rewritten.** They record shares, not direction — +`getParticipantBalances` derives who owes whom from ownership, so a 50/50 flips +from "they owe me" to "I owe them" untouched, `settled` included. Tested both +directions. + +**Order matters when you do this by hand:** split first, then reassign. The +reverse locks you out, which is exactly what the 409 exists to stop. + +`useUpdateTransaction` also gained the missing `res.ok` check. Without it every +rejection resolved as success — the modal closed, the list refetched, and the +edit silently vanished. + ### Statement types `statements.statement_type` is constrained to `credit_card | transaction | @@ -923,6 +972,10 @@ See `README.md` → **Known Gaps / TODOs** for full details. asset disposals into the income line alongside salary. - **`payment_method` is not shown in the transactions list** — settable on create and edit only. Worth a column or filter if cash becomes routine. +- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes + `DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now + (see "Changing who paid"), but a "Paid by" step at capture time would stop the + correction being needed. - **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were committed by accident in `030490e` and remain in that commit's history; the repo has no GitHub remote, so exposure is limited to the local Gitea. Purging history diff --git a/src/__tests__/integration/transaction-owner.test.ts b/src/__tests__/integration/transaction-owner.test.ts new file mode 100644 index 0000000..d628ad7 --- /dev/null +++ b/src/__tests__/integration/transaction-owner.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { queryRaw, queryRow } from "../../lib/db"; + +/** + * Changing who paid. + * + * Owner was write-once for every ingestion path — a pantry receipt hardcodes + * DEFAULT_OWNER_ID, and no route, modal or bulk action could correct it, so a + * shop the other person paid for was permanently filed as yours. There was not + * a single `UPDATE ... SET owner_id` anywhere in src/. + * + * Two things make this more than a field edit, and both are tested here: + * + * 1. **It is a one-way door.** Access is "owner OR holds a split" + * (canAccessTransactions), so handing a row over while holding no split on + * it removes it from your list and 404s every route that could put it back. + * Only the new owner can undo it. + * 2. **It moves money between two ledgers.** Splits record shares, not + * direction — getParticipantBalances derives who owes whom from ownership — + * so the same 50/50 rows flip sides untouched. + */ + +type Res = { status: number; json: () => Promise> }; + +let txPATCH: (req: unknown, ctx: { params: Promise<{ id: string }> }) => Promise; +let stmtPATCH: (req: unknown, ctx: { params: Promise<{ id: string }> }) => Promise; + +const req = (email: string | null, body: unknown) => ({ + headers: { get: (h: string) => (h.toLowerCase() === "x-forwarded-user" ? email : null) }, + json: async () => body, +}); + +const ctx = (id: number) => ({ params: Promise.resolve({ id: String(id) }) }); + +let me = 0; +let them = 0; +let meEmail = ""; +let themEmail = ""; + +beforeAll(async () => { + ({ PATCH: txPATCH } = await import("../../app/api/transactions/[id]/route")); + ({ PATCH: stmtPATCH } = await import("../../app/api/statements/[id]/route")); + + // Fixtures are named, not random, so a re-run is idempotent. `participants` + // has a unique email and no cascade from `transactions.owner_id`, so the + // teardown order is rows → statements → people. + await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Owner fixture — %'`); + await queryRaw(`DELETE FROM statements WHERE bank_name = 'Owner Fixture Bank'`); + await queryRaw(`DELETE FROM participants WHERE email LIKE 'owner-fixture-%@example.test'`); + + const mk = async (label: string) => { + const email = `owner-fixture-${label}@example.test`; + const row = await queryRow<{ id: number }>( + `INSERT INTO participants (name, email) VALUES ($1, $2) RETURNING id`, + [`Owner fixture — ${label}`, email] + ); + return { id: row!.id, email }; + }; + const a = await mk("me"); + const b = await mk("them"); + me = a.id; + meEmail = a.email; + them = b.id; + themEmail = b.email; +}); + +/** A manual transaction owned by `owner`, with optional splits. */ +async function manualTxn(label: string, owner: number, splits: [number, number][] = []) { + const row = await queryRow<{ id: number }>( + `INSERT INTO transactions (transaction_date, description, amount, transaction_type, category, owner_id) + VALUES ('2026-06-01', $1, 100.00, 'debit', 'groceries', $2) RETURNING id`, + [`Owner fixture — ${label}`, owner] + ); + for (const [pid, pct] of splits) { + await queryRaw( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1,$2,$3)`, + [row!.id, pid, pct] + ); + } + return row!.id; +} + +const ownerOf = async (id: number) => + (await queryRow<{ owner_id: number | null }>(`SELECT owner_id FROM transactions WHERE id = $1`, [id]))! + .owner_id; + +describe("PATCH /api/transactions/[id] — owner", () => { + it("reassigns a manual row when I hold a split on it", async () => { + const id = await manualTxn("shared shop", me, [[me, 50], [them, 50]]); + const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id)); + expect(res.status).toBe(200); + expect(await ownerOf(id)).toBe(them); + }); + + it("refuses to hand away a row I hold no split on", async () => { + const id = await manualTxn("nothing of mine", me); + const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id)); + expect(res.status).toBe(409); + expect((await res.json()).code).toBe("would_lose_access"); + // Unchanged — a refusal must not half-apply. + expect(await ownerOf(id)).toBe(me); + }); + + it("hands it away when the release is explicit", async () => { + const id = await manualTxn("given away", me); + const res = await txPATCH(req(meEmail, { owner_id: them, release: true }), ctx(id)); + expect(res.status).toBe(200); + expect(await ownerOf(id)).toBe(them); + }); + + it("never blocks taking onto my ledger a row I can already see", async () => { + // No `release` needed: I end up the owner, so there is no door to close + // behind me. The split is what lets me see it in the first place. + const id = await manualTxn("taken on", them, [[me, 50], [them, 50]]); + const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id)); + expect(res.status).toBe(200); + expect(await ownerOf(id)).toBe(me); + }); + + it("cannot claim a row I cannot see", async () => { + // Their transaction, no split of mine — canAccessTransactions rejects + // before any owner logic runs. Reassignment is a correction to a row you + // already have, never a way to reach one you do not. + const id = await manualTxn("out of reach", them); + const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id)); + expect(res.status).toBe(404); + expect(await ownerOf(id)).toBe(them); + }); + + it("refuses a statement row and says where to change it", async () => { + const stmt = await queryRow<{ id: number }>( + `INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id) + VALUES ('Owner Fixture Bank','1111','2026-06-30','AUD','owner-fixture-1.pdf',$1) RETURNING id`, + [me] + ); + const row = await queryRow<{ id: number }>( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type) + VALUES ($1,'2026-06-02','Owner fixture — statement row', 40.00, 'debit') RETURNING id`, + [stmt!.id] + ); + const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(row!.id)); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("statement_owned"); + }); + + it("rejects a participant that does not exist", async () => { + const id = await manualTxn("bad participant", me, [[me, 100]]); + const res = await txPATCH(req(meEmail, { owner_id: 999999 }), ctx(id)); + expect(res.status).toBe(400); + expect(await ownerOf(id)).toBe(me); + }); + + it("leaves splits untouched — they record shares, not direction", async () => { + const id = await manualTxn("shares survive", me, [[me, 50], [them, 50]]); + await txPATCH(req(meEmail, { owner_id: them }), ctx(id)); + const splits = await queryRaw<{ participant_id: number; share_percent: string; settled: boolean }>( + `SELECT participant_id, share_percent, settled FROM transaction_splits + WHERE transaction_id = $1 ORDER BY participant_id`, + [id] + ); + expect(splits.map((s) => [s.participant_id, Number(s.share_percent)])).toEqual([ + [me, 50], + [them, 50], + ]); + expect(splits.every((s) => s.settled === false)).toBe(true); + }); + + it("flips which side of the balance the same rows sit on", async () => { + const id = await manualTxn("balance flip", me, [[me, 50], [them, 50]]); + + // "They owe me": their split on a row I own. + const owedToMe = async () => + Number( + (await queryRow<{ v: string | null }>( + `SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v + FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id + WHERE t.id = $1 AND t.owner_id = $2 AND ts.participant_id <> $2`, + [id, me] + ))!.v ?? 0 + ); + // "I owe them": my split on a row they own. + const owedByMe = async () => + Number( + (await queryRow<{ v: string | null }>( + `SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v + FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id + WHERE t.id = $1 AND t.owner_id <> $2 AND ts.participant_id = $2`, + [id, me] + ))!.v ?? 0 + ); + + expect(await owedToMe()).toBe(50); + expect(await owedByMe()).toBe(0); + + await txPATCH(req(meEmail, { owner_id: them }), ctx(id)); + + expect(await owedToMe()).toBe(0); + expect(await owedByMe()).toBe(50); + }); + + it("rejects an unauthenticated caller", async () => { + const id = await manualTxn("no auth", me, [[me, 100]]); + const res = await txPATCH(req("nobody@example.test", { owner_id: them }), ctx(id)); + expect(res.status).toBe(403); + expect(await ownerOf(id)).toBe(me); + }); +}); + +describe("PATCH /api/statements/[id] — owner", () => { + async function statementWithRows(label: string, owner: number) { + const stmt = await queryRow<{ id: number }>( + `INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id) + VALUES ('Owner Fixture Bank', $1, '2026-06-30','AUD', $2, $3) RETURNING id`, + [label, `owner-fixture-${label}.pdf`, owner] + ); + // One row inheriting the owner (owner_id NULL) and one carrying its own + // copy — the live table holds 1,803 and 2,194 of these respectively, and + // updating `statements` alone would move only the first kind. + const inheriting = await queryRow<{ id: number }>( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type) + VALUES ($1,'2026-06-03','Owner fixture — inheriting', 10.00, 'debit') RETURNING id`, + [stmt!.id] + ); + const carrying = await queryRow<{ id: number }>( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id) + VALUES ($1,'2026-06-04','Owner fixture — carrying', 20.00, 'debit', $2) RETURNING id`, + [stmt!.id, owner] + ); + return { stmtId: stmt!.id, inheriting: inheriting!.id, carrying: carrying!.id }; + } + + /** The owner the app actually reads: COALESCE(t.owner_id, s.owner_id). */ + const effectiveOwner = async (id: number) => + (await queryRow<{ v: number }>( + `SELECT COALESCE(t.owner_id, s.owner_id) AS v FROM transactions t + LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = $1`, + [id] + ))!.v; + + it("moves the statement and every row on it, both kinds", async () => { + const { stmtId, inheriting, carrying } = await statementWithRows("move", me); + const res = await stmtPATCH(req(meEmail, { owner_id: them }), ctx(stmtId)); + expect(res.status).toBe(200); + expect((await res.json()).rows_moved).toBe(1); // only the one carrying a copy + + expect(await effectiveOwner(inheriting)).toBe(them); + expect(await effectiveOwner(carrying)).toBe(them); + }); + + it("refuses a caller who does not own it", async () => { + const { stmtId, carrying } = await statementWithRows("not-mine", me); + const res = await stmtPATCH(req(themEmail, { owner_id: them }), ctx(stmtId)); + expect(res.status).toBe(404); + expect(await effectiveOwner(carrying)).toBe(me); + }); + + it("rejects a participant that does not exist", async () => { + const { stmtId, carrying } = await statementWithRows("bad-participant", me); + const res = await stmtPATCH(req(meEmail, { owner_id: 999999 }), ctx(stmtId)); + expect(res.status).toBe(400); + expect(await effectiveOwner(carrying)).toBe(me); + }); + + it("is a no-op when the owner is unchanged", async () => { + const { stmtId } = await statementWithRows("same-owner", me); + const res = await stmtPATCH(req(meEmail, { owner_id: me }), ctx(stmtId)); + expect(res.status).toBe(200); + expect((await res.json()).rows_moved).toBe(0); + }); +}); diff --git a/src/app/api/statements/[id]/route.ts b/src/app/api/statements/[id]/route.ts index 5530c9f..3f24980 100644 --- a/src/app/api/statements/[id]/route.ts +++ b/src/app/api/statements/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getStatementById } from "@/lib/queries"; import { getCurrentUser } from "@/lib/auth"; +import { prisma, queryRaw } from "@/lib/db"; export async function GET( req: NextRequest, @@ -15,3 +16,59 @@ export async function GET( } return NextResponse.json(stmt); } + +/** + * Reassign a statement — whose account it is. + * + * The statements page has had an owner dropdown since it was built, wired to + * `useUpdateStatement`, which PATCHed a route that had no PATCH handler. Every + * change 405'd, and because the hook never checked `res.ok` the failure was + * silent: the select snapped back on refetch and looked like a UI glitch. + * + * Both tables are written because both carry the owner. The effective owner is + * `COALESCE(t.owner_id, s.owner_id)`, and 2,194 statement rows carry their own + * copy against 1,803 that inherit — so updating `statements` alone would move + * less than half the rows and split one account's history between two people. + * All 2,194 agree with their statement today; a row that disagrees was set + * deliberately and is left alone rather than swept up. + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + const { id } = await params; + const statementId = Number(id); + + const stmt = await getStatementById(statementId); + // Only the current owner reassigns. A statement is an account, not a shared + // expense — there is no split that grants a second person a say in it. + if (!stmt || stmt.owner_id !== user.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const { owner_id } = (await req.json()) as { owner_id?: number }; + if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) { + return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 }); + } + const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]); + if (!known.length) { + return NextResponse.json({ error: "Unknown participant" }, { status: 400 }); + } + if (owner_id === stmt.owner_id) { + return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: 0 }); + } + + const [, rowsMoved] = await prisma.$transaction([ + prisma.$executeRawUnsafe(`UPDATE statements SET owner_id = $1 WHERE id = $2`, owner_id, statementId), + prisma.$executeRawUnsafe( + `UPDATE transactions SET owner_id = $1 WHERE statement_id = $2 AND owner_id = $3`, + owner_id, + statementId, + stmt.owner_id + ), + ]); + + return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: rowsMoved }); +} diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts index 22b46c2..91aabec 100644 --- a/src/app/api/transactions/[id]/route.ts +++ b/src/app/api/transactions/[id]/route.ts @@ -34,7 +34,7 @@ export async function PATCH( } const body = await req.json(); - const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method } = body as { + const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method, owner_id, release } = body as { category?: string; merchant_normalized?: string; notes?: string; @@ -45,6 +45,9 @@ export async function PATCH( amount?: number; transaction_date?: string; trip_id?: number | null; + owner_id?: number; + /** Acknowledge that reassigning will remove the row from my own view. */ + release?: boolean; }; if (my_share_percent !== undefined && my_share_percent !== null) { @@ -98,6 +101,75 @@ export async function PATCH( ); } + // Owner — who actually paid. A direct column, not an override: it decides + // whose account the money left, which every balance and every spend analytic + // scopes on (MY_SPEND_SCOPE / OWNER_SCOPE). + // + // Handled here rather than in the direct-fields block above because it has + // its own rules, and because getting it wrong is not a cosmetic error: it + // moves money between two people's ledgers. + if (owner_id !== undefined) { + if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) { + return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 }); + } + + const txRows = await queryRaw<{ statement_id: number | null; owner_id: number | null }>( + `SELECT statement_id, owner_id FROM transactions WHERE id = $1`, + [transactionId] + ); + const tx = txRows[0]; + if (!tx) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + // A statement row's owner is the statement's owner — the effective owner is + // COALESCE(t.owner_id, s.owner_id), so setting it here would either be a + // no-op or would silently detach one row from the account it was extracted + // from. Reassigning the statement is the correct move and moves its rows + // with it. + if (tx.statement_id) { + return NextResponse.json( + { + error: + "This row came from a statement, so its owner is the statement's owner. Change the statement's owner instead — that moves every row on it.", + code: "statement_owned", + }, + { status: 400 } + ); + } + + const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]); + if (!known.length) { + return NextResponse.json({ error: "Unknown participant" }, { status: 400 }); + } + + // The one-way door. Access is "owner OR holds a split" + // (canAccessTransactions), so handing a row to someone else while holding + // no split on it removes it from the caller's list and 404s every route + // that could put it back — only the new owner can undo it. Splitting first + // keeps the row reachable AND is what makes the balance correct, so the + // refusal points at the step that was skipped rather than just blocking. + if (owner_id !== user.id && !release) { + const mine = await queryRaw( + `SELECT 1 FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`, + [transactionId, user.id] + ); + if (!mine.length) { + return NextResponse.json( + { + error: + "You hold no split on this transaction, so reassigning it would remove it from your view for good — only the new owner could change it back. Add your split first, or confirm you are giving it away entirely.", + code: "would_lose_access", + }, + { status: 409 } + ); + } + } + + // Existing splits are deliberately left alone. They record shares, not + // direction: getParticipantBalances derives who owes whom from ownership, + // so a 50/50 row flips from "they owe me" to "I owe them" with no rewrite. + await queryRaw(`UPDATE transactions SET owner_id = $1 WHERE id = $2`, [owner_id, transactionId]); + } + // category/merchant/notes/my_share_percent/trip_id go through the overrides table const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined; if (!hasOverride) { diff --git a/src/app/statements/page.tsx b/src/app/statements/page.tsx index 48b59ac..75b4f79 100644 --- a/src/app/statements/page.tsx +++ b/src/app/statements/page.tsx @@ -255,9 +255,24 @@ export default function StatementsPage() { {participants?.length ? ( { + setOwnerId(e.target.value ? Number(e.target.value) : null); + setReleasePrompt(false); + setError(""); + }} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + > + {participants.map((p) => ( + + ))} + + {ownerChanged && ( +

+ {ownerId === me?.id + ? "This moves the spend onto your ledger, and any split you hold becomes their share of it." + : iHoldASplit + ? "This moves the spend onto their ledger. Your split becomes what you owe them rather than what they owe you." + : "You hold no split on this. Add one first, or it leaves your view for good."} +

+ )} + + ) : ( +
+

{transaction.owner_name ?? "—"}

+

+ From a statement, so the owner is the statement's. Change it on the + Statements page to move every row on that statement. +

+
+ )} + + {/* Splits */}
@@ -320,9 +383,31 @@ export function EditTransactionModal({
{/* Footer */} -
- {error &&

{error}

} -
+
+ {error &&

{error}

} + {/* The refusal names the step that was skipped, so offer both: add + the split (keeps the row reachable and gets the balance right) + or hand it over knowingly. */} + {releasePrompt && ( +
+ + +
+ )} +