Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion api/expenses-router.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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]) {
Expand Down
87 changes: 54 additions & 33 deletions api/reports-router.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,46 @@
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<number[]> {
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));

const expenseCond: any[] = [
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<string>`COALESCE(SUM(${expenses.amount}), 0)` })
.from(expenses)
Expand All @@ -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;
Expand All @@ -68,17 +83,17 @@ 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));

const expenseCond: any[] = [
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<string>`COALESCE(SUM(${expenses.amount}), 0)` })
.from(expenses)
Expand All @@ -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<string>`COALESCE(SUM(${expenses.amount}), 0)` }).from(expenses).where(and(...expenseCond));
const expensesTotal = d(expenseResult[0]?.total || "0");

Expand All @@ -131,19 +146,21 @@ 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`;

const expenseCond: any[] = [
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({
Expand Down Expand Up @@ -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];
Expand All @@ -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);
Expand All @@ -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));

Expand All @@ -214,19 +233,21 @@ 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`;

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));

Expand All @@ -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<string>`COALESCE(SUM(${expenses.amount}), 0)` })
.from(expenses)
Expand All @@ -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");
Expand Down
29 changes: 29 additions & 0 deletions db/migrations/0002_soft_flamingo.sql
Original file line number Diff line number Diff line change
@@ -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)';
Loading
Loading