Repository navigation
Description
What is wrong
Picks (transactions.user_category) and budgets (budgets.category) point at a category by its name, and nothing in the database ties that name to a category. PR #253 (migration 023) makes a rename or a delete move the category's picks and budget all or nothing. Two other writers can still save the old name afterwards.
- A Budgets page loaded before the rename.
app/api/budgets/route.ts:17-25accepts any non-empty category name.components/BudgetEditor.tsx:109sends every name the page loaded. So Save, on a Budgets page opened before "Pets" was renamed to "Animals", upserts a budget under "Pets" again. - A pick racing a rename or a delete.
app/api/transactions/categorize/route.tsreads the category list (step 5,:92-101), then writes the pick in a separate request (step 6,:113). A rename or delete that commits between the two leaves the pick under the old name.
Consequence
- The stale budget. Budgets doesn't show it, but the Dashboard's "budgeted" total adds up every budget row (
app/(app)/dashboard/page.tsx:261-263). So the category's limit counts twice. With illustrative figures, a $100 limit renamed and then saved from the stale page makes the total $200. - The racing pick counts as spending under a name no category has, whatever kind the category was, so an income pick moves into spending. The window is the gap between two requests, so this is rare.
- Both can be found with
db/seeds/023_category_rename_delete_checks.sqlfrom PR Renaming or deleting a category is all or nothing, and names the app uses are refused (#100, #117) #253.
How it was found
The SQL review of PR #253. Both were replayed in PGlite with migrations 001 to 023 and made-up rows. The budget case was also confirmed by reading the route and BudgetEditor.
Suggested fix
Tie the names to categories in the database, with composite foreign keys onto categories (household_id, name), which is unique (007):
transactions (household_id, user_category) references categories (household_id, name) on update cascade on delete set null (user_category). The column list onset nullneeds Postgres 15 or later.budgets (household_id, category) references categories (household_id, name) on update cascade on delete cascade.
A stale write then fails with 23503. The two routes could answer that with "Showing the latest" and a reload. A rename or delete would also move picks and budgets by itself, so 023's functions could shrink to the category write.
Before adding the keys, clear the leftovers the 023 checks query lists, or add the keys not valid and validate them later.
A smaller fix for budgets alone: refuse a name the household doesn't have, with a 409, which makes BudgetEditor reload.
Activity