fix(budget): stop receipts deleting files, and share the upload path

Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
This commit is contained in:
Maurice
2026-09-08 21:37:49 +02:00
committed by Maurice
parent 06ecc5135c
commit 6a3d4b1281
34 changed files with 525 additions and 198 deletions
+8 -1
View File
@@ -4425,12 +4425,19 @@ function runMigrations(db: Database.Database): void {
* SQLite treats NULLs as distinct, so the rows that exist today, which all
* carry a NULL here, do not collide with each other.
*
* SET NULL rather than CASCADE, unlike the three columns beside it, because
* those predate the shared row: one row can carry a place link AND a receipt
* link for the same file, and a cascade would delete the whole row when the
* expense goes, silently detaching the file from the place as well. Deleting
* the expense drops the receipt link and nothing else; the budget service
* removes the row afterwards when it carries no other link.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>;
if (!flCols.some((c) => c.name === 'budget_item_id')) {
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE CASCADE');
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
+48 -58
View File
@@ -204,34 +204,36 @@ export class BudgetService {
}
/**
* Move a receipt file to trash if it is only linked to this budget item and
* not referenced by any other reservation, place, or day assignment.
* Drop this item's receipt links, leaving the files themselves alone.
*
* The files are deliberately NOT trashed. `budget_edit` and `file_delete` are
* separate, separately configurable permissions, and a receipt id is any file
* on the trip, so trashing here would let anyone who may edit an expense
* delete a document they may not delete, from three surfaces that never see a
* file permission at all: the REST route, the MCP tool and the plugin RPC.
* Cleaning up a file nobody references is what the Files trash is for.
*
* A row is only removed when the receipt link was all it carried. The same
* row can also tie the file to a place or a booking, and those links have
* nothing to do with the expense. A link whose file already sits in the trash
* is left in place, so restoring that file brings it back attached.
*/
private trashOrphanReceipt(fileId: number | string, budgetItemId: number | string) {
const file = this.db.get<{ id: number; place_id: number | null; reservation_id: number | null }>(
'SELECT id, place_id, reservation_id FROM trip_files WHERE id = ? AND deleted_at IS NULL',
fileId,
private unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet<number> = new Set()) {
const rows = this.db.all<{ id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }>(
`SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id
FROM file_links fl
JOIN trip_files f ON f.id = fl.file_id
WHERE fl.budget_item_id = ? AND f.deleted_at IS NULL`,
budgetItemId,
);
if (!file) return;
// Check if file is tied directly to another entity via legacy columns
if (file.place_id || file.reservation_id) return;
// Check if file is linked via file_links to another reservation, place, assignment, or other budget item
const otherLinks = this.db.get<{ count: number }>(`
SELECT COUNT(*) as count FROM file_links
WHERE file_id = ? AND (
reservation_id IS NOT NULL OR
place_id IS NOT NULL OR
assignment_id IS NOT NULL OR
(budget_item_id IS NOT NULL AND budget_item_id != ?)
)
`, fileId, budgetItemId);
if (otherLinks && otherLinks.count > 0) return;
// It is an orphan receipt solely belonging to this budget item: move to trash
this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', fileId);
for (const row of rows) {
if (keep.has(row.file_id)) continue;
if (row.reservation_id || row.assignment_id || row.place_id) {
this.db.run('UPDATE file_links SET budget_item_id = NULL WHERE id = ?', row.id);
} else {
this.db.run('DELETE FROM file_links WHERE id = ?', row.id);
}
}
}
// -------------------------------------------------------------------------
@@ -628,30 +630,24 @@ export class BudgetService {
}
if (data.receipt_file_ids !== undefined) {
// Find existing receipt file IDs for this budget item
const oldReceiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Sync receipts: delete previous links and add new ones
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The ids that survive are left linked rather than dropped and re-added,
// so a receipt the request keeps never loses its row for an instant.
const keep = new Set(data.receipt_file_ids.map(Number));
this.unlinkReceipts(id, keep);
if (data.receipt_file_ids.length > 0) {
const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)');
const adopt = this.db.prepare('UPDATE file_links SET budget_item_id = ? WHERE id = ?');
for (const fid of data.receipt_file_ids) {
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
if (belongs) {
insertLink.run(fid, id);
}
}
}
// Any previously attached receipt that was unlinked now moves to trash if orphan
const newSet = new Set(data.receipt_file_ids);
for (const oldFid of oldReceiptFiles) {
if (!newSet.has(oldFid)) {
this.trashOrphanReceipt(oldFid, id);
if (!belongs) continue;
// A file already tied to a place or a booking gets the receipt link
// written onto that row, because the unique index is per file and
// item and a second row for the same pair would be refused anyway.
const spare = this.db.get<{ id: number }>(
'SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1', fid,
);
if (spare) adopt.run(id, spare.id);
else insertLink.run(fid, id);
}
}
}
@@ -687,18 +683,12 @@ export class BudgetService {
if (!item) return false;
return this.db.transaction(() => {
// Find all receipts attached to this item before deleting it
const receiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Move exclusively attached receipts to trash
for (const fid of receiptFiles) {
this.trashOrphanReceipt(fid, id);
}
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The receipts stay; only their tie to this expense goes. Rows that
// carry nothing else are removed, rows that also point at a place or a
// booking keep those. A link on a file already in the trash is left for
// the SET NULL on the foreign key, so restoring the file does not bring
// back a pointer to an expense that no longer exists.
this.unlinkReceipts(id);
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
// The booking keeps a copy of this expense's total in its metadata, and
@@ -974,7 +974,7 @@ describe('an expense whose split leaves a remainder', () => {
expect(found?.receipts?.[0].id).toBe(fileId);
});
it('updates receipts on updateBudgetItem, trashing orphans and keeping shared receipts', () => {
it('updates receipts on updateBudgetItem without deleting any file', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
@@ -994,18 +994,18 @@ describe('an expense whose split leaves a remainder', () => {
const updated = budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] });
expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort());
// Check database state
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f4Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f4) as { deleted_at: string | null };
expect(f4Row.deleted_at).toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
// Removing a receipt removes the link and nothing else. The file stays on
// the trip: deleting it needs file_delete, which this path never checks.
for (const fid of [f1, f2, f3, f4]) {
const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE budget_item_id = ?').get(item.id)).toEqual({ c: 2 });
// f4 keeps the link it has to the other expense.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(f4)).toEqual({ c: 1 });
});
it('trashes orphan receipts on deleteBudgetItem, but preserves shared files', () => {
it('unlinks receipts on deleteBudgetItem and leaves every file in place', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
@@ -1019,39 +1019,66 @@ describe('an expense whose split leaves a remainder', () => {
const deleted = budget.deleteBudgetItem(item.id, trip.id);
expect(deleted).toBe(true);
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
// Neither file is touched; only the links to the deleted expense go.
for (const fid of [f1, f2]) {
const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fid) as { deleted_at: string | null };
expect(row.deleted_at).toBeNull();
}
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(item.id);
expect(linkRows).toHaveLength(0);
});
it('handles all branches of trashOrphanReceipt safely', () => {
it('unlinks receipts without ever trashing the file itself', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// Branch 1: non-existent file or already deleted
const item = budget.createBudgetItem(trip.id, { name: 'BranchTest' });
expect(() => budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] })).not.toThrow();
// budget_edit and file_delete are separate permissions and a receipt id is
// any file on the trip, so the budget domain must never delete one.
const plain = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'r.pdf', 'r.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [plain] });
budget.deleteBudgetItem(item.id, trip.id);
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(plain) as { deleted_at: string | null }).deleted_at).toBeNull();
// The link is gone, because it was all the row carried.
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(plain)).toEqual({ c: 0 });
});
// Branch 2: file linked to reservation
const resId = Number(testDb.prepare('INSERT INTO reservations (trip_id, title) VALUES (?, ?)').run(trip.id, 'Flight').lastInsertRowid);
const fRes = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, reservation_id) VALUES (?, ?, ?, ?)').run(trip.id, 'res.pdf', 'res.pdf', resId).lastInsertRowid);
const item2 = budget.createBudgetItem(trip.id, { name: 'ResItem', receipt_file_ids: [fRes] });
budget.deleteBudgetItem(item2.id, trip.id);
const fResRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fRes) as { deleted_at: string | null };
expect(fResRow.deleted_at).toBeNull();
it('keeps a place link on a row that also carried the receipt link', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const place = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'menu.pdf', 'menu.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file, place);
// Branch 3: file linked via file_links to a place or assignment
const place2 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const fLink = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'link.pdf', 'link.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(fLink, place2);
const item3 = budget.createBudgetItem(trip.id, { name: 'LinkItem', receipt_file_ids: [fLink] });
budget.deleteBudgetItem(item3.id, trip.id);
const fLinkRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fLink) as { deleted_at: string | null };
expect(fLinkRow.deleted_at).toBeNull();
const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [file] });
budget.deleteBudgetItem(item.id, trip.id);
const row = testDb.prepare('SELECT place_id, budget_item_id FROM file_links WHERE file_id = ?').get(file) as { place_id: number | null; budget_item_id: number | null };
expect(row.place_id).toBe(place);
expect(row.budget_item_id).toBeNull();
expect((testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file) as { deleted_at: string | null }).deleted_at).toBeNull();
});
it('leaves a receipt already in the trash linked, so restoring it comes back attached', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'old.pdf', 'old.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', receipt_file_ids: [file] });
testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(file);
// A save that no longer mentions the trashed receipt must not drop its link.
budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id)).toEqual({ c: 1 });
});
it('an edit that keeps a receipt does not churn its link row', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
const file = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'keep.pdf', 'keep.pdf').lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', receipt_file_ids: [file] });
const before = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
budget.updateBudgetItem(item.id, trip.id, { name: 'Hotel 2', receipt_file_ids: [file] });
const after = testDb.prepare('SELECT id FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file, item.id) as { id: number };
expect(after.id).toBe(before.id);
});
});
@@ -251,12 +251,64 @@ describe('listFiles', () => {
expect(bareRow.linked_reservation_ids).toEqual([]);
expect(bareRow.linked_place_ids).toEqual([]);
const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid);
svc.createFileLink(linked.id, { budget_item_id: item });
const withReceipt = (svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === linked.id);
expect(withReceipt.linked_budget_item_ids).toEqual([item]);
expect((svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]);
// The empty-trip guard skips the IN () batch entirely.
const empty = createTrip(testDb, user.id);
expect(svc.listFiles(empty.id, false)).toEqual([]);
});
});
// ── receipts on an expense ────────────────────────────────────────────────────
describe('budget receipts', () => {
function seedItem(tripId: number) {
return Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(tripId, 'Dinner').lastInsertRowid);
}
it('FILE-SVC-040: an upload naming an expense gets its link row straight away', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
});
it('FILE-SVC-041: an upload without one writes no link at all', () => {
const { user, trip } = seedTrip();
const file = makeFile(trip.id, user.id);
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 0 });
});
it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id);
svc.updateFile(file.id, file, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
// Sending it twice must not double the row.
svc.updateFile(file.id, file, { budget_item_id: item });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 1 });
svc.updateFile(file.id, file, { budget_item_id: null });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL').get(file.id)).toEqual({ c: 0 });
});
it('FILE-SVC-043: leaving budget_item_id out touches no link', () => {
const { user, trip } = seedTrip();
const item = seedItem(trip.id);
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
svc.updateFile(file.id, file, { description: 'renamed' });
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
});
});
// ── createFile / updateFile / toggleStarred ───────────────────────────────────
describe('createFile', () => {
@@ -424,6 +476,12 @@ describe('findForeignLinkTarget', () => {
expect(svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id');
expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull();
// A receipt may only point at an expense on the same trip.
const foreignItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(foreign.id, 'Foreign').lastInsertRowid);
const myItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(mine.id, 'Mine').lastInsertRowid);
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id');
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull();
});
it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', () => {