4c0eae4b11
- Move EXPENSE_CATEGORIES to ~/lib/expense-categories.ts to break server router import chain from client component - Use inline import() types in trpc/react.tsx to prevent Turbopack from including server modules (pg, db) in the client bundle - Replace next/font/google with system font stacks to fix build failures in environments without Google Fonts access https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
import { z } from "zod";
|
|
import { eq, and, desc } from "drizzle-orm";
|
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
|
|
|
export { EXPENSE_CATEGORIES };
|
|
|
|
const createExpenseSchema = z.object({
|
|
date: z.date(),
|
|
description: z.string().min(1, "Description is required"),
|
|
amount: z.number().min(0, "Amount must be positive"),
|
|
currency: z.string().length(3).default("USD"),
|
|
category: z.string().optional().or(z.literal("")),
|
|
billable: z.boolean().default(false),
|
|
reimbursable: z.boolean().default(false),
|
|
notes: z.string().optional().or(z.literal("")),
|
|
clientId: z.string().optional().or(z.literal("")),
|
|
businessId: z.string().optional().or(z.literal("")),
|
|
invoiceId: z.string().optional().or(z.literal("")),
|
|
});
|
|
|
|
const updateExpenseSchema = createExpenseSchema.partial().extend({
|
|
id: z.string(),
|
|
});
|
|
|
|
export const expensesRouter = createTRPCRouter({
|
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
|
return await ctx.db.query.expenses.findMany({
|
|
where: eq(expenses.createdById, ctx.session.user.id),
|
|
with: { client: true, business: true, invoice: true },
|
|
orderBy: [desc(expenses.date)],
|
|
});
|
|
}),
|
|
|
|
getById: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const expense = await ctx.db.query.expenses.findFirst({
|
|
where: and(
|
|
eq(expenses.id, input.id),
|
|
eq(expenses.createdById, ctx.session.user.id),
|
|
),
|
|
with: { client: true, business: true, invoice: true },
|
|
});
|
|
|
|
if (!expense) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
|
}
|
|
|
|
return expense;
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(createExpenseSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const clean = {
|
|
...input,
|
|
clientId: input.clientId?.trim() || null,
|
|
businessId: input.businessId?.trim() || null,
|
|
invoiceId: input.invoiceId?.trim() || null,
|
|
category: input.category?.trim() || null,
|
|
notes: input.notes?.trim() || null,
|
|
};
|
|
|
|
if (clean.clientId) {
|
|
const client = await ctx.db.query.clients.findFirst({
|
|
where: and(
|
|
eq(clients.id, clean.clientId),
|
|
eq(clients.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
|
}
|
|
|
|
if (clean.businessId) {
|
|
const business = await ctx.db.query.businesses.findFirst({
|
|
where: and(
|
|
eq(businesses.id, clean.businessId),
|
|
eq(businesses.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" });
|
|
}
|
|
|
|
if (clean.invoiceId) {
|
|
const invoice = await ctx.db.query.invoices.findFirst({
|
|
where: and(
|
|
eq(invoices.id, clean.invoiceId),
|
|
eq(invoices.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
|
}
|
|
|
|
const [expense] = await ctx.db
|
|
.insert(expenses)
|
|
.values({ ...clean, createdById: ctx.session.user.id })
|
|
.returning();
|
|
|
|
return expense;
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(updateExpenseSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...data } = input;
|
|
|
|
const existing = await ctx.db.query.expenses.findFirst({
|
|
where: and(
|
|
eq(expenses.id, id),
|
|
eq(expenses.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
|
}
|
|
|
|
const clean = {
|
|
...data,
|
|
clientId: data.clientId?.trim() || null,
|
|
businessId: data.businessId?.trim() || null,
|
|
invoiceId: data.invoiceId?.trim() || null,
|
|
category: data.category?.trim() || null,
|
|
notes: data.notes?.trim() || null,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.db.query.expenses.findFirst({
|
|
where: and(
|
|
eq(expenses.id, input.id),
|
|
eq(expenses.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
|
}
|
|
|
|
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
|
|
|
return { success: true };
|
|
}),
|
|
});
|