From b9a2262e8467476acd30cc30821d3f9a77dd0f6b Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Mon, 18 May 2026 18:58:13 +0300 Subject: [PATCH 1/5] feat(expenses): add multi-category bill support - refactor bill item category extraction to return all unique categories instead of single - add groupBillItemsByCategory utility to organize bill items by their category ID - implement logic to create individual expense entries for each category in multi-category bills - reorder and improve form validation checks for amount, description, and future expense dates - update bill category error messaging for better clarity --- src/pages/Expenses.tsx | 94 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/src/pages/Expenses.tsx b/src/pages/Expenses.tsx index 6d41ada..2639d48 100644 --- a/src/pages/Expenses.tsx +++ b/src/pages/Expenses.tsx @@ -138,10 +138,23 @@ export function Expenses() { const selectedSupplier = suppliers?.find(s => s.id.toString() === form.supplierId); const selectedBill = bills?.find(b => b.id.toString() === form.billId); - const getCategoryFromBillItems = (items: typeof billItems): number | undefined => { - if (!items || items.length === 0) return undefined; + const getCategoriesFromBillItems = (items: typeof billItems): number[] => { + if (!items || items.length === 0) return []; const uniqueCategories = [...new Set(items.map(item => item.categoryId).filter(Boolean))]; - return uniqueCategories.length === 1 ? uniqueCategories[0] : undefined; + return uniqueCategories as number[]; + }; + + const groupBillItemsByCategory = (items: typeof billItems): Map => { + const grouped = new Map(); + if (!items) return grouped; + for (const item of items) { + const catId = item.categoryId; + if (!grouped.has(catId)) { + grouped.set(catId, []); + } + grouped.get(catId)!.push(item); + } + return grouped; }; useEffect(() => { @@ -152,9 +165,9 @@ export function Expenses() { if (selectedBill.categoryId) { categoryId = String(selectedBill.categoryId); } else if (billItems && billItems.length > 0) { - const itemCategory = getCategoryFromBillItems(billItems); - if (itemCategory) { - categoryId = String(itemCategory); + const categories = getCategoriesFromBillItems(billItems); + if (categories.length === 1) { + categoryId = String(categories[0]); } } @@ -198,17 +211,76 @@ export function Expenses() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!form.locationId) { toast.error("Please select a location"); return; } + if (!form.amount || parseFloat(form.amount) <= 0) { toast.error("Please enter a valid amount"); return; } + if (!form.description.trim()) { toast.error("Please enter a description"); return; } + if (form.expenseDate > todayDate) { toast.error("Expense date cannot be in the future"); return; } + + const hasBillWithItems = form.billId && billItems && billItems.length > 0; + const categories = hasBillWithItems ? getCategoriesFromBillItems(billItems) : []; + const hasMultiCategoryItems = hasBillWithItems && categories.length > 1; + const billHasOwnCategory = selectedBill?.categoryId; + if (!form.categoryId) { - if (form.billId) { - toast.error("The selected bill has no category. Please edit the bill to add a category, or select a category manually."); + if (form.billId && (billHasOwnCategory || hasMultiCategoryItems)) { + // Bill has its own category or multi-category items - proceed + } else if (form.billId) { + toast.error("The selected bill has no category. Please edit the bill to add items with categories, or select a category manually."); + return; } else { toast.error("Please select a category"); + return; } + } + + if (hasMultiCategoryItems && !billHasOwnCategory) { + const grouped = groupBillItemsByCategory(billItems); + const expenseCount = grouped.size; + let createdCount = 0; + + const expenseDataArray = Array.from(grouped.entries()).map(([categoryId, items]) => { + const totalAmount = items.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0); + const category = categories?.find(c => c === categoryId); + const catName = category ? categories?.find(c => c === categoryId) : "uncategorized"; + return { + locationId: +form.locationId, + categoryId: categoryId ? +categoryId : +form.categoryId, + supplierId: form.supplierId ? +form.supplierId : undefined, + amount: totalAmount.toFixed(2), + description: `${form.description} (${items.map(i => i.itemName).join(", ")})`, + expenseDate: form.expenseDate, + paymentMethod: form.paymentMethod, + accountId: form.accountId ? +form.accountId : undefined, + billId: form.billId ? +form.billId : undefined, + attachments: undefined as undefined, + }; + }); + + toast.info(`Creating ${expenseCount} expense entries for multi-category bill...`); + + const createNextExpense = (index: number) => { + if (index >= expenseDataArray.length) { + toast.success(`Created ${createdCount} expenses successfully`); + setOpen(false); + resetForm(); + refetch(); + return; + } + createExpense.mutate(expenseDataArray[index], { + onSuccess: () => { + createdCount++; + createNextExpense(index + 1); + }, + onError: (err) => { + toast.error(`Failed to create expense: ${err.message}`); + createNextExpense(index + 1); + } + }); + }; + + createNextExpense(0); return; } - if (!form.amount || parseFloat(form.amount) <= 0) { toast.error("Please enter a valid amount"); return; } - if (!form.description.trim()) { toast.error("Please enter a description"); return; } - if (form.expenseDate > todayDate) { toast.error("Expense date cannot be in the future"); return; } + createExpense.mutate({ locationId: +form.locationId, categoryId: +form.categoryId, supplierId: form.supplierId ? +form.supplierId : undefined, amount: form.amount, description: form.description, expenseDate: form.expenseDate, From db0769f01c6847bda162ede7aa9446cf9d9adb1a Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Mon, 18 May 2026 19:08:53 +0300 Subject: [PATCH 2/5] feat: add budget validation and fix expense logic - add real-time budget validation when adding bill items to prevent exceeding bill total - add summary row displaying bill amount, items total and remaining budget with alerts - fix expense page logic: correct boolean check, simplify conditionals and update error messages - clean up redundant code and improve type safety across both pages --- src/pages/Bills.tsx | 36 ++++++++++++++++++++++++++++++++++-- src/pages/Expenses.tsx | 8 ++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/pages/Bills.tsx b/src/pages/Bills.tsx index d139b0f..c6f8231 100644 --- a/src/pages/Bills.tsx +++ b/src/pages/Bills.tsx @@ -44,6 +44,13 @@ export function Bills() { ); const utils = trpc.useUtils(); + + const selectedBillForItems = itemsOpen ? bills?.find(b => b.id === itemsOpen) : null; + const itemsTotal = billItemsData?.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0) ?? 0; + const billAmount = selectedBillForItems ? parseFloat(selectedBillForItems.amount) : 0; + const remainingAmount = billAmount - itemsTotal; + const isOverBudget = remainingAmount < 0; + const createBill = trpc.bills.create.useMutation({ onSuccess: () => { setOpen(false); utils.bills.list.invalidate(); } }); const createRecurring = trpc.bills.createRecurring.useMutation({ onSuccess: () => { setRecurringOpen(false); utils.bills.listRecurring.invalidate(); } }); const deleteRecurring = trpc.bills.deleteRecurring.useMutation({ onSuccess: () => { utils.bills.listRecurring.invalidate(); utils.bills.list.invalidate(); } }); @@ -160,16 +167,22 @@ export function Bills() { if (!itemsOpen) return; const qty = parseFloat(itemForm.quantity) || 1; const price = parseFloat(itemForm.unitPrice) || 0; + const itemTotal = qty * price; + + if (billAmount > 0 && itemsTotal + itemTotal > billAmount) { + toast.error(`Item total (${formatKES(itemTotal)}) would exceed bill amount (${formatKES(billAmount)}). Remaining: ${formatKES(remainingAmount)}`); + return; + } + addItem.mutate({ billId: itemsOpen, itemName: itemForm.itemName, quantity: qty.toString(), unitPrice: price.toFixed(2), - totalPrice: (qty * price).toFixed(2), + totalPrice: itemTotal.toFixed(2), categoryId: itemForm.categoryId ? +itemForm.categoryId : undefined, notes: itemForm.notes, }); - // Reset form but keep suggestions ready setItemForm({ itemName: "", quantity: "1", unitPrice: "", totalPrice: "", categoryId: "", notes: "" }); setShowSuggestions(false); }; @@ -346,6 +359,25 @@ export function Bills() {
{billItemsData?.map(item => )}{(!billItemsData || billItemsData.length === 0) && }
ItemQtyUnit PriceTotalCategory
{item.itemName}{item.quantity}{formatKES(item.unitPrice)}{formatKES(item.totalPrice)}{categories?.find(c => c.id === item.categoryId)?.name ?? "-"}
No items yet.
+ {selectedBillForItems && ( +
0 ? "bg-green-50 border border-green-200" : ""}`}> +
+

Bill Amount

+

{formatKES(billAmount)}

+
+
+

Items Total

+

{formatKES(itemsTotal)}

+
+
+

Remaining

+

= billAmount ? "text-green-600" : ""}`}> + {isOverBudget && } + {isOverBudget ? `OVER by ${formatKES(Math.abs(remainingAmount))}` : formatKES(remainingAmount)} +

+
+
+ )} {canCreate && (
diff --git a/src/pages/Expenses.tsx b/src/pages/Expenses.tsx index 2639d48..e667757 100644 --- a/src/pages/Expenses.tsx +++ b/src/pages/Expenses.tsx @@ -218,13 +218,13 @@ export function Expenses() { const hasBillWithItems = form.billId && billItems && billItems.length > 0; const categories = hasBillWithItems ? getCategoriesFromBillItems(billItems) : []; const hasMultiCategoryItems = hasBillWithItems && categories.length > 1; - const billHasOwnCategory = selectedBill?.categoryId; + const billHasOwnCategory = !!selectedBill?.categoryId; if (!form.categoryId) { - if (form.billId && (billHasOwnCategory || hasMultiCategoryItems)) { - // Bill has its own category or multi-category items - proceed + if (hasBillWithItems) { + // Bill has items - allow proceeding even without category (will create expenses from items) } else if (form.billId) { - toast.error("The selected bill has no category. Please edit the bill to add items with categories, or select a category manually."); + toast.error("The selected bill has no items. Please add items to the bill or select a category manually."); return; } else { toast.error("Please select a category"); From b11bcacb711f48615f91e8373a1e14851b52acaf Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Mon, 18 May 2026 19:27:14 +0300 Subject: [PATCH 3/5] feat(Expenses): add multi-category expense support Replace the single `categoryId` form state with a `categoryIds` array to support multiple selected categories. Add dynamic UI for toggling categories per bill line item when a bill has multiple unique categories, update validation logic, form effects, and submission handling to create grouped expenses per selected category, and adjust error messages and form reset to match the new structure. --- src/pages/Expenses.tsx | 119 ++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/src/pages/Expenses.tsx b/src/pages/Expenses.tsx index e667757..0d83f7a 100644 --- a/src/pages/Expenses.tsx +++ b/src/pages/Expenses.tsx @@ -120,7 +120,7 @@ export function Expenses() { }); const [form, setForm] = useState({ - locationId: "", categoryId: "", supplierId: "", amount: "", description: "", + locationId: "", categoryIds: [] as number[], supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash" as const, accountId: "", billId: "", }); @@ -134,16 +134,18 @@ export function Expenses() { { enabled: !!selectedBillId } ); - const photosEnabled = settings?.photosExpenses !== "false"; - const selectedSupplier = suppliers?.find(s => s.id.toString() === form.supplierId); - const selectedBill = bills?.find(b => b.id.toString() === form.billId); - const getCategoriesFromBillItems = (items: typeof billItems): number[] => { if (!items || items.length === 0) return []; const uniqueCategories = [...new Set(items.map(item => item.categoryId).filter(Boolean))]; return uniqueCategories as number[]; }; + const photosEnabled = settings?.photosExpenses !== "false"; + const selectedSupplier = suppliers?.find(s => s.id.toString() === form.supplierId); + const selectedBill = bills?.find(b => b.id.toString() === form.billId); + const itemCategories = billItems ? getCategoriesFromBillItems(billItems) : []; + const hasMultiCategoryItems = !!form.billId && !!billItems && billItems.length > 0 && itemCategories.length > 1; + const groupBillItemsByCategory = (items: typeof billItems): Map => { const grouped = new Map(); if (!items) return grouped; @@ -160,36 +162,33 @@ export function Expenses() { useEffect(() => { if (!form.billId || !selectedBill) return; - let categoryId: string | undefined; + let categoryIds: number[] = []; if (selectedBill.categoryId) { - categoryId = String(selectedBill.categoryId); + categoryIds = [selectedBill.categoryId]; } else if (billItems && billItems.length > 0) { - const categories = getCategoriesFromBillItems(billItems); - if (categories.length === 1) { - categoryId = String(categories[0]); - } + categoryIds = getCategoriesFromBillItems(billItems); } - if (!categoryId && selectedSupplier?.autoCategoryId) { - categoryId = String(selectedSupplier.autoCategoryId); + if (categoryIds.length === 0 && selectedSupplier?.autoCategoryId) { + categoryIds = [selectedSupplier.autoCategoryId]; } setForm(p => ({ ...p, supplierId: selectedBill.supplierId ? String(selectedBill.supplierId) : p.supplierId, - categoryId: categoryId ?? p.categoryId, + categoryIds: categoryIds.length > 0 ? categoryIds : p.categoryIds, })); }, [form.billId, selectedBill, billItems, selectedSupplier?.autoCategoryId]); useEffect(() => { - if (!form.billId && !form.categoryId && selectedSupplier?.autoCategoryId) { + if (!form.billId && form.categoryIds.length === 0 && selectedSupplier?.autoCategoryId) { setForm((previous) => ({ ...previous, - categoryId: String(selectedSupplier.autoCategoryId), + categoryIds: [selectedSupplier.autoCategoryId], })); } - }, [form.billId, form.categoryId, selectedSupplier?.autoCategoryId]); + }, [form.billId, form.categoryIds, selectedSupplier?.autoCategoryId]); const handleFile = async (e: React.ChangeEvent) => { const files = e.target.files; @@ -204,7 +203,7 @@ export function Expenses() { }; const resetForm = () => { - setForm({ locationId: "", categoryId: "", supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash", accountId: "", billId: "" }); + setForm({ locationId: "", categoryIds: [], supplierId: "", amount: "", description: "", expenseDate: getLocalDateString(), paymentMethod: "cash", accountId: "", billId: "" }); setAttachments([]); }; @@ -216,18 +215,18 @@ export function Expenses() { if (form.expenseDate > todayDate) { toast.error("Expense date cannot be in the future"); return; } const hasBillWithItems = form.billId && billItems && billItems.length > 0; - const categories = hasBillWithItems ? getCategoriesFromBillItems(billItems) : []; - const hasMultiCategoryItems = hasBillWithItems && categories.length > 1; + const itemCategories = hasBillWithItems ? getCategoriesFromBillItems(billItems) : []; + const hasMultiCategoryItems = hasBillWithItems && itemCategories.length > 1; const billHasOwnCategory = !!selectedBill?.categoryId; - if (!form.categoryId) { + if (form.categoryIds.length === 0) { if (hasBillWithItems) { - // Bill has items - allow proceeding even without category (will create expenses from items) + // Bill has items - allow proceeding (will create expenses from items) } else if (form.billId) { toast.error("The selected bill has no items. Please add items to the bill or select a category manually."); return; } else { - toast.error("Please select a category"); + toast.error("Please select at least one category"); return; } } @@ -239,11 +238,9 @@ export function Expenses() { const expenseDataArray = Array.from(grouped.entries()).map(([categoryId, items]) => { const totalAmount = items.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0); - const category = categories?.find(c => c === categoryId); - const catName = category ? categories?.find(c => c === categoryId) : "uncategorized"; return { locationId: +form.locationId, - categoryId: categoryId ? +categoryId : +form.categoryId, + categoryId: categoryId ? +categoryId : form.categoryIds[0], supplierId: form.supplierId ? +form.supplierId : undefined, amount: totalAmount.toFixed(2), description: `${form.description} (${items.map(i => i.itemName).join(", ")})`, @@ -281,8 +278,9 @@ export function Expenses() { return; } + const primaryCategoryId = form.categoryIds[0] ?? 0; createExpense.mutate({ - locationId: +form.locationId, categoryId: +form.categoryId, supplierId: form.supplierId ? +form.supplierId : undefined, + locationId: +form.locationId, categoryId: primaryCategoryId, supplierId: form.supplierId ? +form.supplierId : undefined, amount: form.amount, description: form.description, expenseDate: form.expenseDate, paymentMethod: form.paymentMethod, accountId: form.accountId ? +form.accountId : undefined, billId: form.billId ? +form.billId : undefined, attachments: photosEnabled ? attachments : undefined, @@ -368,16 +366,65 @@ export function Expenses() { {locations?.map(l => )}
-
- - {form.billId && selectedBill?.categoryId &&

Category is determined by the linked bill.

} - {form.billId && !selectedBill?.categoryId && selectedSupplier?.autoCategoryId &&

Using the supplier default category until you override it.

} - {form.billId && !selectedBill?.categoryId && !selectedSupplier?.autoCategoryId &&

This bill has no saved category yet, so please choose one.

} -
+ {!hasMultiCategoryItems && ( +
+ + + {form.billId && selectedBill?.categoryId &&

Category from linked bill.

} + {form.billId && !selectedBill?.categoryId && selectedSupplier?.autoCategoryId &&

Using supplier default.

} +
+ )} + + {hasMultiCategoryItems && billItems && billItems.length > 0 && ( +
+
+

Bill Items — select categories for expenses:

+ {form.categoryIds.length} of {itemCategories.length} selected +
+
+ {billItems.map(item => { + const cat = categories?.find(c => c.id === item.categoryId); + const isSelected = item.categoryId && form.categoryIds.includes(item.categoryId); + return ( + + ); + })} +
+

Click categories to toggle. Each selected category creates a separate expense.

+
+ )} + + {hasMultiCategoryItems && form.categoryIds.length === 0 && ( +

Please select at least one category from the bill items above.

+ )}
KES From b46dda67bc3dc5aa25d6bd26aa235abf43b1568d Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Mon, 18 May 2026 20:42:21 +0300 Subject: [PATCH 4/5] feat: add multi-category expense line item support This commit adds full support for multi-category expenses by introducing expense line items: - create the expense_items database table with indexes and foreign keys - add drizzle ORM schema and table relations - extend the expenses API to accept line items in creation requests and add a getItems endpoint - update the frontend Expenses page to use line items instead of multiple single expenses for multi-category bills --- api/expenses-router.ts | 38 ++++++++++- db/migrations/0002_soft_flamingo.sql | 29 +++++++++ db/migrations/meta/0002_snapshot.json | 54 +++++++++++++++ db/migrations/meta/_journal.json | 9 ++- db/relations.ts | 15 ++++- db/schema.ts | 17 +++++ src/pages/Expenses.tsx | 94 +++++++++++++++------------ 7 files changed, 212 insertions(+), 44 deletions(-) create mode 100644 db/migrations/0002_soft_flamingo.sql create mode 100644 db/migrations/meta/0002_snapshot.json diff --git a/api/expenses-router.ts b/api/expenses-router.ts index 6c0dad1..1004f86 100644 --- a/api/expenses-router.ts +++ b/api/expenses-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createRouter, expenseQuery, expenseCreate, expenseManage, getCurrentBusinessLocationIds, requireAuthorizedLocation, requireAuthorizedEntity, requireAuthorizedBusinessEntity } from "./middleware"; import { getDb } from "./queries/connection"; -import { expenses, expenseCategories, accounts, ledgerEntries, suppliers, bills, attachments, locations } from "@db/schema"; +import { expenses, expenseItems, expenseCategories, accounts, ledgerEntries, suppliers, bills, attachments, locations } from "@db/schema"; import { eq, and, isNull, desc, sql } from "drizzle-orm"; import { d } from "./lib/decimal"; import { notFutureDateString, optionalNotFutureDateString } from "./lib/future-date"; @@ -10,6 +10,19 @@ import { ensureSystemAccount } from "./lib/accounting-accounts"; import { getExpenseAccountSubType } from "./lib/accounting-maps"; import { reverseLedgerEntriesForTransaction } from "./lib/accounting-reversal"; +export const expenseItemInputSchema = z.object({ + itemName: z.string().min(1), + quantity: z.string().default("1"), + unitPrice: z.string(), + totalPrice: z.string(), + categoryId: z.number(), + notes: z.string().optional(), +}); + +export const expenseItemWithIdSchema = expenseItemInputSchema.extend({ + id: z.number().optional(), +}); + export const createExpenseInputSchema = z.object({ locationId: z.number(), categoryId: z.number(), @@ -38,6 +51,7 @@ export const createExpenseInputSchema = z.object({ depreciationMethod: z.enum(["straight_line", "declining_balance"]).optional(), salvageValue: z.string().optional(), assetAccountId: z.number().optional(), + items: z.array(expenseItemInputSchema).optional(), }); export const updateExpenseInputSchema = z.object({ @@ -230,6 +244,14 @@ export const expensesRouter = createRouter({ return db.select().from(expenses).where(and(...conditions)).orderBy(desc(expenses.expenseDate)).limit(input.pageSize).offset(offset); }), + getItems: expenseQuery + .input(z.object({ expenseId: z.number() })) + .query(async ({ input, ctx }) => { + const db = getDb(); + await requireAuthorizedEntity(ctx, expenses, input.expenseId); + return db.select().from(expenseItems).where(and(eq(expenseItems.expenseId, input.expenseId), isNull(expenseItems.deletedAt))); + }), + create: expenseCreate .input(createExpenseInputSchema) .mutation(async ({ input, ctx }) => { @@ -319,6 +341,20 @@ export const expensesRouter = createRouter({ } } + if (input.items && input.items.length > 0) { + for (const item of input.items) { + await tx.insert(expenseItems).values({ + expenseId, + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: item.categoryId, + notes: item.notes, + } as any).returning(); + } + } + if (accountId) { const cashAcct = await tx.select().from(accounts).where(eq(accounts.id, accountId)).limit(1); if (cashAcct[0]) { diff --git a/db/migrations/0002_soft_flamingo.sql b/db/migrations/0002_soft_flamingo.sql new file mode 100644 index 0000000..649108d --- /dev/null +++ b/db/migrations/0002_soft_flamingo.sql @@ -0,0 +1,29 @@ +-- Migration: Add expense_items table for expense line items with multiple categories +-- Created for: Multi-category expense support + +CREATE TABLE IF NOT EXISTS "expense_items" ( + "id" serial PRIMARY KEY, + "expenseId" bigint NOT NULL, + "itemName" varchar(255) NOT NULL, + "quantity" numeric(10, 3) DEFAULT '1.000' NOT NULL, + "unitPrice" numeric(15, 2) NOT NULL, + "totalPrice" numeric(15, 2) NOT NULL, + "categoryId" bigint NOT NULL, + "notes" text, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "deletedAt" timestamp +); + +-- Index for querying items by expense +CREATE INDEX IF NOT EXISTS "expense_items_expense_id_idx" ON "expense_items" ("expenseId"); +CREATE INDEX IF NOT EXISTS "expense_items_category_id_idx" ON "expense_items" ("categoryId"); +CREATE INDEX IF NOT EXISTS "expense_items_deleted_at_idx" ON "expense_items" ("deletedAt"); + +-- Add foreign key constraint +ALTER TABLE "expense_items" ADD CONSTRAINT "expense_items_expense_id_fkey" FOREIGN KEY ("expenseId") REFERENCES "expenses"("id") ON DELETE NO ACTION; +ALTER TABLE "expense_items" ADD CONSTRAINT "expense_items_category_id_fkey" FOREIGN KEY ("categoryId") REFERENCES "expense_categories"("id") ON DELETE NO ACTION; + +COMMENT ON TABLE "expense_items" IS 'Line items for expenses with multiple category support'; +COMMENT ON COLUMN "expense_items"."expenseId" IS 'Reference to the parent expense'; +COMMENT ON COLUMN "expense_items"."categoryId" IS 'Category for this line item (allows different categories per expense)'; diff --git a/db/migrations/meta/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fa10221 --- /dev/null +++ b/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,54 @@ +{ + "id": "0002-soft-flamingo", + "prevId": "0001-gifted-secret-warriors", + "version": "7", + "dialect": "postgresql", + "tables": { + "expense_items": { + "name": "expense_items", + "columns": { + "id": { "name": "id", "type": "serial", "primaryKey": true, "autoincrement": true }, + "expenseId": { "name": "expenseId", "type": "bigint", "primaryKey": false, "autoincrement": false }, + "itemName": { "name": "itemName", "type": "varchar(255)", "primaryKey": false, "autoincrement": false }, + "quantity": { "name": "quantity", "type": "numeric(10, 3)", "primaryKey": false, "autoincrement": false, "default": "'1.000'" }, + "unitPrice": { "name": "unitPrice", "type": "numeric(15, 2)", "primaryKey": false, "autoincrement": false }, + "totalPrice": { "name": "totalPrice", "type": "numeric(15, 2)", "primaryKey": false, "autoincrement": false }, + "categoryId": { "name": "categoryId", "type": "bigint", "primaryKey": false, "autoincrement": false }, + "notes": { "name": "notes", "type": "text", "primaryKey": false, "autoincrement": false }, + "createdAt": { "name": "createdAt", "type": "timestamp", "primaryKey": false, "autoincrement": false, "default": "now()" }, + "updatedAt": { "name": "updatedAt", "type": "timestamp", "primaryKey": false, "autoincrement": false, "default": "now()" }, + "deletedAt": { "name": "deletedAt", "type": "timestamp", "primaryKey": false, "autoincrement": false } + }, + "indexes": { + "expense_items_expense_id_idx": { "name": "expense_items_expense_id_idx", "columns": ["expenseId"] }, + "expense_items_category_id_idx": { "name": "expense_items_category_id_idx", "columns": ["categoryId"] }, + "expense_items_deleted_at_idx": { "name": "expense_items_deleted_at_idx", "columns": ["deletedAt"] } + }, + "foreignKeys": { + "expense_items_expense_id_fkey": { + "name": "expense_items_expense_id_fkey", + "tableFrom": "expense_items", + "tableTo": "expenses", + "columnsFrom": ["expenseId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "expense_items_category_id_fkey": { + "name": "expense_items_category_id_fkey", + "tableFrom": "expense_items", + "tableTo": "expense_categories", + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {} +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index cb82ecc..f0129f7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779017871967, "tag": "0001_gifted_secret_warriors", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779122000000, + "tag": "0002_soft_flamingo", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/db/relations.ts b/db/relations.ts index 34c420d..b04acdd 100644 --- a/db/relations.ts +++ b/db/relations.ts @@ -9,6 +9,7 @@ import { accounts, dailySales, expenses, + expenseItems, expenseCategories, suppliers, bills, @@ -102,7 +103,7 @@ export const masterItemsRelations = relations(masterItems, ({ one }) => ({ }), })); -export const expensesRelations = relations(expenses, ({ one }) => ({ +export const expensesRelations = relations(expenses, ({ one, many }) => ({ location: one(locations, { fields: [expenses.locationId], references: [locations.id], @@ -119,6 +120,18 @@ export const expensesRelations = relations(expenses, ({ one }) => ({ fields: [expenses.accountId], references: [accounts.id], }), + items: many(expenseItems), +})); + +export const expenseItemsRelations = relations(expenseItems, ({ one }) => ({ + expense: one(expenses, { + fields: [expenseItems.expenseId], + references: [expenses.id], + }), + category: one(expenseCategories, { + fields: [expenseItems.categoryId], + references: [expenseCategories.id], + }), })); export const employeesRelations = relations(employees, ({ one, many }) => ({ diff --git a/db/schema.ts b/db/schema.ts index f9669c5..1b3456f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -328,6 +328,23 @@ export const expenses = pgTable("expenses", { export type Expense = typeof expenses.$inferSelect; +// Expense line items - for expenses with multiple categories +export const expenseItems = pgTable("expense_items", { + id: serial("id").primaryKey(), + expenseId: bigint("expenseId", { mode: "number" }).notNull(), + itemName: varchar("itemName", { length: 255 }).notNull(), + quantity: numeric("quantity", { precision: 10, scale: 3 }).default("1.000").notNull(), + unitPrice: numeric("unitPrice", { precision: 15, scale: 2 }).notNull(), + totalPrice: numeric("totalPrice", { precision: 15, scale: 2 }).notNull(), + categoryId: bigint("categoryId", { mode: "number" }).notNull(), + notes: text("notes"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().notNull().$onUpdate(() => new Date()), + deletedAt: timestamp("deletedAt"), +}); + +export type ExpenseItem = typeof expenseItems.$inferSelect; + // Suppliers export const suppliers = pgTable("suppliers", { id: serial("id").primaryKey(), diff --git a/src/pages/Expenses.tsx b/src/pages/Expenses.tsx index 0d83f7a..9d5a86a 100644 --- a/src/pages/Expenses.tsx +++ b/src/pages/Expenses.tsx @@ -221,7 +221,7 @@ export function Expenses() { if (form.categoryIds.length === 0) { if (hasBillWithItems) { - // Bill has items - allow proceeding (will create expenses from items) + // Bill has items - allow proceeding (will create expense with items) } else if (form.billId) { toast.error("The selected bill has no items. Please add items to the bill or select a category manually."); return; @@ -233,48 +233,60 @@ export function Expenses() { if (hasMultiCategoryItems && !billHasOwnCategory) { const grouped = groupBillItemsByCategory(billItems); - const expenseCount = grouped.size; - let createdCount = 0; - - const expenseDataArray = Array.from(grouped.entries()).map(([categoryId, items]) => { - const totalAmount = items.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0); - return { - locationId: +form.locationId, - categoryId: categoryId ? +categoryId : form.categoryIds[0], - supplierId: form.supplierId ? +form.supplierId : undefined, - amount: totalAmount.toFixed(2), - description: `${form.description} (${items.map(i => i.itemName).join(", ")})`, - expenseDate: form.expenseDate, - paymentMethod: form.paymentMethod, - accountId: form.accountId ? +form.accountId : undefined, - billId: form.billId ? +form.billId : undefined, - attachments: undefined as undefined, - }; + const expenseItems = Array.from(grouped.entries()).flatMap(([categoryId, items]) => { + return items.map(item => ({ + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: categoryId ? Number(categoryId) : form.categoryIds[0], + notes: undefined as string | undefined, + })); }); - toast.info(`Creating ${expenseCount} expense entries for multi-category bill...`); - - const createNextExpense = (index: number) => { - if (index >= expenseDataArray.length) { - toast.success(`Created ${createdCount} expenses successfully`); - setOpen(false); - resetForm(); - refetch(); - return; - } - createExpense.mutate(expenseDataArray[index], { - onSuccess: () => { - createdCount++; - createNextExpense(index + 1); - }, - onError: (err) => { - toast.error(`Failed to create expense: ${err.message}`); - createNextExpense(index + 1); - } - }); - }; - - createNextExpense(0); + const totalAmount = expenseItems.reduce((sum, item) => sum + parseFloat(String(item.totalPrice)), 0); + + toast.info("Creating expense with line items..."); + + createExpense.mutate({ + locationId: +form.locationId, + categoryId: form.categoryIds[0], + supplierId: form.supplierId ? +form.supplierId : undefined, + amount: totalAmount.toFixed(2), + description: form.description, + expenseDate: form.expenseDate, + paymentMethod: form.paymentMethod, + accountId: form.accountId ? +form.accountId : undefined, + billId: form.billId ? +form.billId : undefined, + attachments: photosEnabled ? attachments : undefined, + items: expenseItems, + }); + return; + } + + if (hasBillWithItems && billItems && billItems.length > 0) { + const expenseItems = billItems.map(item => ({ + itemName: item.itemName, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + categoryId: item.categoryId ? Number(item.categoryId) : form.categoryIds[0], + notes: undefined as string | undefined, + })); + + createExpense.mutate({ + locationId: +form.locationId, + categoryId: form.categoryIds[0], + supplierId: form.supplierId ? +form.supplierId : undefined, + amount: form.amount, + description: form.description, + expenseDate: form.expenseDate, + paymentMethod: form.paymentMethod, + accountId: form.accountId ? +form.accountId : undefined, + billId: form.billId ? +form.billId : undefined, + attachments: photosEnabled ? attachments : undefined, + items: expenseItems, + }); return; } From edd30546d103b05374f749e61ecf8fd4146d9852 Mon Sep 17 00:00:00 2001 From: Martin Bhuong Date: Mon, 18 May 2026 20:55:25 +0300 Subject: [PATCH 5/5] fix(reports-router): enforce business location access for all reports Add a resolveLocationFilter utility to validate and resolve allowed business locations for the current user. Switch all report routes to use reportQuery middleware instead of accountManage, update handlers to accept request context, and replace manual input location ID checks with validated business-level filters to ensure reports only access permitted data. --- api/reports-router.ts | 87 +++++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/api/reports-router.ts b/api/reports-router.ts index bbf8770..2dcc859 100644 --- a/api/reports-router.ts +++ b/api/reports-router.ts @@ -1,24 +1,37 @@ import { z } from "zod"; -import { createRouter, accountManage } from "./middleware"; +import { createRouter, reportQuery, getCurrentBusinessLocationIds } from "./middleware"; import { getDb } from "./queries/connection"; import { dailySales, expenses, bills, expenseCategories, accounts, financialReports } from "@db/schema"; import { eq, and, isNull, sql, inArray } from "drizzle-orm"; import { d } from "./lib/decimal"; +async function resolveLocationFilter(ctx: any, inputLocationId?: number): Promise { + const locIds = await getCurrentBusinessLocationIds(ctx); + if (inputLocationId !== undefined) { + if (!locIds.includes(inputLocationId)) { + throw new Error("Invalid location for the current business"); + } + return [inputLocationId]; + } + return locIds; +} + export const reportsRouter = createRouter({ - plStatement: accountManage + plStatement: reportQuery .input(z.object({ year: z.number(), month: z.number(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); const startDate = `${input.year}-${String(input.month).padStart(2, "0")}-01`; const endDate = new Date(input.year, input.month, 0).toISOString().split("T")[0]; + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const salesCond: any[] = [ sql`${dailySales.saleDate} >= ${startDate}`, sql`${dailySales.saleDate} <= ${endDate}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -26,8 +39,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${startDate}`, sql`${expenses.expenseDate} <= ${endDate}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -49,10 +62,12 @@ export const reportsRouter = createRouter({ }; }), - plMonthly: accountManage + plMonthly: reportQuery .input(z.object({ year: z.number(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const months: { month: number; monthName: string; @@ -68,8 +83,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${monthStart}`, sql`${dailySales.saleDate} <= ${monthEnd}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -77,8 +92,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${monthStart}`, sql`${expenses.expenseDate} <= ${monthEnd}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -96,23 +111,23 @@ export const reportsRouter = createRouter({ return months; }), - plComparative: accountManage + plComparative: reportQuery .input(z.object({ locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const thisYear = new Date().getFullYear(); const lastYear = thisYear - 1; const getTotals = async (year: number) => { const start = `${year}-01-01`; const end = `${year}-12-31`; - const salesCond: any[] = [sql`${dailySales.saleDate} >= ${start}`, sql`${dailySales.saleDate} <= ${end}`, isNull(dailySales.deletedAt)]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); + const salesCond: any[] = [sql`${dailySales.saleDate} >= ${start}`, sql`${dailySales.saleDate} <= ${end}`, isNull(dailySales.deletedAt), sql`${dailySales.locationId} IN (${locIdSql})`]; const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); - const expenseCond: any[] = [sql`${expenses.expenseDate} >= ${start}`, sql`${expenses.expenseDate} <= ${end}`, isNull(expenses.deletedAt)]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); + const expenseCond: any[] = [sql`${expenses.expenseDate} >= ${start}`, sql`${expenses.expenseDate} <= ${end}`, isNull(expenses.deletedAt), sql`${expenses.locationId} IN (${locIdSql})`]; const expenseResult = await db.select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }).from(expenses).where(and(...expenseCond)); const expensesTotal = d(expenseResult[0]?.total || "0"); @@ -131,10 +146,12 @@ export const reportsRouter = createRouter({ }; }), - budgetVsActual: accountManage + budgetVsActual: reportQuery .input(z.object({ year: z.number(), month: z.number().optional(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const startDate = `${input.year}-01-01`; const endDate = input.month ? new Date(input.year, input.month, 0).toISOString().split("T")[0] : `${input.year}-12-31`; @@ -142,8 +159,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} >= ${startDate}`, sql`${expenses.expenseDate} <= ${endDate}`, isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) expenseCond.push(eq(expenses.locationId, input.locationId)); const expenseData = await db .select({ @@ -176,10 +193,12 @@ export const reportsRouter = createRouter({ }; }), - cashFlowForecast: accountManage + cashFlowForecast: reportQuery .input(z.object({ locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0]; const next30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; @@ -188,8 +207,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${thirtyDaysAgo}`, sql`${dailySales.saleDate} <= ${today}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const totalSales = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); const avgDaily = totalSales.gt(0) ? totalSales.dividedBy(30) : d(0); @@ -199,8 +218,8 @@ export const reportsRouter = createRouter({ sql`${bills.dueDate} <= ${next30}`, sql`${bills.balanceDue} > 0`, isNull(bills.deletedAt), + sql`${bills.locationId} IN (${locIdSql})`, ]; - if (input.locationId) billCond.push(eq(bills.locationId, input.locationId)); const billData = await db.select().from(bills).where(and(...billCond)); const totalBills = billData.reduce((sum, b) => sum.plus(d(b.balanceDue || "0")), d(0)); @@ -214,10 +233,12 @@ export const reportsRouter = createRouter({ }; }), - cogsAnalysis: accountManage + cogsAnalysis: reportQuery .input(z.object({ year: z.number(), month: z.number().optional(), locationId: z.number().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = getDb(); + const locFilter = await resolveLocationFilter(ctx, input.locationId); + const locIdSql = sql.join(locFilter.map(id => sql`${id}`), sql`, `); const startDate = input.month ? `${input.year}-${String(input.month).padStart(2, "0")}-01` : `${input.year}-01-01`; const endDate = input.month ? new Date(input.year, input.month, 0).toISOString().split("T")[0] : `${input.year}-12-31`; @@ -225,8 +246,8 @@ export const reportsRouter = createRouter({ sql`${dailySales.saleDate} >= ${startDate}`, sql`${dailySales.saleDate} <= ${endDate}`, isNull(dailySales.deletedAt), + sql`${dailySales.locationId} IN (${locIdSql})`, ]; - if (input.locationId) salesCond.push(eq(dailySales.locationId, input.locationId)); const salesData = await db.select().from(dailySales).where(and(...salesCond)); const revenue = salesData.reduce((sum, s) => sum.plus(d(s.netSales || "0")), d(0)); @@ -244,8 +265,8 @@ export const reportsRouter = createRouter({ sql`${expenses.expenseDate} <= ${endDate}`, inArray(expenses.categoryId, cogsCatIds), isNull(expenses.deletedAt), + sql`${expenses.locationId} IN (${locIdSql})`, ]; - if (input.locationId) cogsCond.push(eq(expenses.locationId, input.locationId)); const cogsResult = await db .select({ total: sql`COALESCE(SUM(${expenses.amount}), 0)` }) .from(expenses) @@ -272,43 +293,43 @@ export const reportsRouter = createRouter({ }; }), - getCogsTarget: accountManage + getCogsTarget: reportQuery .input(z.object({ locationId: z.number().optional() })) .query(async () => ({ targetFoodCostPercent: "35", alertThresholdPercent: "38", })), - setBudget: accountManage + setBudget: reportQuery .input(z.object({ year: z.number(), month: z.number(), budgetType: z.enum(["cogs", "sales"]), amount: z.string(), locationId: z.number() })) .mutation(async () => ({ success: true })), - setCogsTarget: accountManage + setCogsTarget: reportQuery .input(z.object({ locationId: z.number(), cogsTarget: z.string() })) .mutation(async () => ({ success: true })), - incomeStatement: accountManage + incomeStatement: reportQuery .input(z.object({ businessId: z.number(), startDate: z.string(), endDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateIncomeStatement } = await import("./lib/reports"); return generateIncomeStatement(input.businessId, new Date(input.startDate), new Date(input.endDate)); }), - balanceSheet: accountManage + balanceSheet: reportQuery .input(z.object({ businessId: z.number(), asOfDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateBalanceSheet } = await import("./lib/reports"); return generateBalanceSheet(input.businessId, new Date(input.asOfDate)); }), - trialBalance: accountManage + trialBalance: reportQuery .input(z.object({ businessId: z.number(), asOfDate: z.string(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateTrialBalance } = await import("./lib/reports"); return generateTrialBalance(input.businessId, new Date(input.asOfDate)); }), - assetRegister: accountManage + assetRegister: reportQuery .input(z.object({ businessId: z.number(), saveReport: z.boolean().default(false) })) .mutation(async ({ input }) => { const { generateAssetRegister } = await import("./lib/reports");