400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
import { eq, and, desc } from "drizzle-orm";
|
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|
import {
|
|
expenses,
|
|
clients,
|
|
invoices,
|
|
expenseReceipts,
|
|
} from "~/server/db/schema";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
|
import {
|
|
resolveBusinessForExpense,
|
|
verifyBusinessAccess,
|
|
} from "~/server/api/lib/business-access";
|
|
import {
|
|
deleteObject,
|
|
isAllowedReceiptMime,
|
|
putObject,
|
|
RECEIPT_MAX_BYTES,
|
|
} from "~/lib/object-storage";
|
|
|
|
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),
|
|
taxDeductible: 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(),
|
|
});
|
|
|
|
async function verifyClientAccess(
|
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
|
clientId: string,
|
|
) {
|
|
const client = await ctx.db.query.clients.findFirst({
|
|
where: and(
|
|
eq(clients.id, clientId),
|
|
eq(clients.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!client) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Client not found",
|
|
});
|
|
}
|
|
return client;
|
|
}
|
|
|
|
async function verifyInvoiceAccess(
|
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
|
invoiceId: string,
|
|
) {
|
|
const invoice = await ctx.db.query.invoices.findFirst({
|
|
where: and(
|
|
eq(invoices.id, invoiceId),
|
|
eq(invoices.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!invoice) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Invoice not found",
|
|
});
|
|
}
|
|
return invoice;
|
|
}
|
|
|
|
async function resolveExpenseBusinessId(
|
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
|
businessId: string | null,
|
|
invoice?: { businessId: string | null } | null,
|
|
) {
|
|
const explicitBusinessId =
|
|
businessId && businessId.trim() !== "" ? businessId : null;
|
|
const inheritedBusinessId =
|
|
!explicitBusinessId && invoice?.businessId ? invoice.businessId : null;
|
|
|
|
const resolved = await resolveBusinessForExpense(
|
|
ctx,
|
|
explicitBusinessId ?? inheritedBusinessId,
|
|
);
|
|
return resolved?.id ?? null;
|
|
}
|
|
|
|
async function getOwnedExpense(
|
|
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
|
expenseId: string,
|
|
) {
|
|
const expense = await ctx.db.query.expenses.findFirst({
|
|
where: and(
|
|
eq(expenses.id, expenseId),
|
|
eq(expenses.createdById, ctx.session.user.id),
|
|
),
|
|
});
|
|
if (!expense) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Expense not found",
|
|
});
|
|
}
|
|
return expense;
|
|
}
|
|
|
|
export const expensesRouter = createTRPCRouter({
|
|
getAll: protectedProcedure
|
|
.input(
|
|
z
|
|
.object({
|
|
businessId: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const conditions = [eq(expenses.createdById, ctx.session.user.id)];
|
|
|
|
if (input?.businessId) {
|
|
await verifyBusinessAccess(ctx, input.businessId);
|
|
conditions.push(eq(expenses.businessId, input.businessId));
|
|
}
|
|
|
|
return await ctx.db.query.expenses.findMany({
|
|
where: and(...conditions),
|
|
with: {
|
|
client: true,
|
|
business: true,
|
|
invoice: true,
|
|
receipts: 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,
|
|
receipts: 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) {
|
|
await verifyClientAccess(ctx, clean.clientId);
|
|
}
|
|
|
|
const invoice = clean.invoiceId
|
|
? await verifyInvoiceAccess(ctx, clean.invoiceId)
|
|
: null;
|
|
|
|
const businessId = await resolveExpenseBusinessId(
|
|
ctx,
|
|
clean.businessId,
|
|
invoice,
|
|
);
|
|
|
|
const [expense] = await ctx.db
|
|
.insert(expenses)
|
|
.values({
|
|
...clean,
|
|
businessId,
|
|
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 updates: Record<string, unknown> = { updatedAt: new Date() };
|
|
|
|
if (data.date !== undefined) updates.date = data.date;
|
|
if (data.description !== undefined) updates.description = data.description;
|
|
if (data.amount !== undefined) updates.amount = data.amount;
|
|
if (data.currency !== undefined) updates.currency = data.currency;
|
|
if (data.billable !== undefined) updates.billable = data.billable;
|
|
if (data.reimbursable !== undefined) updates.reimbursable = data.reimbursable;
|
|
if (data.taxDeductible !== undefined) updates.taxDeductible = data.taxDeductible;
|
|
if (data.category !== undefined) {
|
|
updates.category = data.category?.trim() ?? null;
|
|
}
|
|
if (data.notes !== undefined) {
|
|
updates.notes = data.notes?.trim() ?? null;
|
|
}
|
|
|
|
const nextClientId =
|
|
data.clientId !== undefined ? data.clientId?.trim() || null : existing.clientId;
|
|
if (data.clientId !== undefined) {
|
|
if (nextClientId) await verifyClientAccess(ctx, nextClientId);
|
|
updates.clientId = nextClientId;
|
|
}
|
|
|
|
const nextInvoiceId =
|
|
data.invoiceId !== undefined
|
|
? data.invoiceId?.trim() || null
|
|
: existing.invoiceId;
|
|
let invoice = null;
|
|
if (data.invoiceId !== undefined) {
|
|
invoice = nextInvoiceId
|
|
? await verifyInvoiceAccess(ctx, nextInvoiceId)
|
|
: null;
|
|
updates.invoiceId = nextInvoiceId;
|
|
} else if (nextInvoiceId) {
|
|
invoice = await verifyInvoiceAccess(ctx, nextInvoiceId);
|
|
}
|
|
|
|
if (data.businessId !== undefined || data.invoiceId !== undefined) {
|
|
const nextBusinessInput =
|
|
data.businessId !== undefined
|
|
? data.businessId?.trim() || null
|
|
: existing.businessId;
|
|
updates.businessId = await resolveExpenseBusinessId(
|
|
ctx,
|
|
nextBusinessInput,
|
|
invoice,
|
|
);
|
|
}
|
|
|
|
await ctx.db.update(expenses).set(updates).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),
|
|
),
|
|
with: { receipts: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Expense not found",
|
|
});
|
|
}
|
|
|
|
await Promise.all(
|
|
existing.receipts.map((receipt) => deleteObject(receipt.storageKey)),
|
|
);
|
|
|
|
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
listReceipts: protectedProcedure
|
|
.input(z.object({ expenseId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
await getOwnedExpense(ctx, input.expenseId);
|
|
|
|
return ctx.db.query.expenseReceipts.findMany({
|
|
where: eq(expenseReceipts.expenseId, input.expenseId),
|
|
orderBy: [desc(expenseReceipts.createdAt)],
|
|
});
|
|
}),
|
|
|
|
uploadReceipt: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
expenseId: z.string(),
|
|
filename: z.string().min(1).max(255),
|
|
mimeType: z.string().min(1).max(100),
|
|
data: z.string().min(1),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await getOwnedExpense(ctx, input.expenseId);
|
|
|
|
if (!isAllowedReceiptMime(input.mimeType)) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Only images and PDF files are allowed",
|
|
});
|
|
}
|
|
|
|
const body = Buffer.from(input.data, "base64");
|
|
if (body.length === 0) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "File is empty",
|
|
});
|
|
}
|
|
if (body.length > RECEIPT_MAX_BYTES) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "File exceeds 10MB limit",
|
|
});
|
|
}
|
|
|
|
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
const storageKey = `receipts/${ctx.session.user.id}/${input.expenseId}/${crypto.randomUUID()}-${safeName}`;
|
|
|
|
await putObject(storageKey, body, input.mimeType);
|
|
|
|
const [receipt] = await ctx.db
|
|
.insert(expenseReceipts)
|
|
.values({
|
|
expenseId: input.expenseId,
|
|
storageKey,
|
|
originalFilename: input.filename,
|
|
mimeType: input.mimeType,
|
|
sizeBytes: body.length,
|
|
})
|
|
.returning();
|
|
|
|
return receipt;
|
|
}),
|
|
|
|
deleteReceipt: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const receipt = await ctx.db.query.expenseReceipts.findFirst({
|
|
where: eq(expenseReceipts.id, input.id),
|
|
with: { expense: true },
|
|
});
|
|
|
|
if (
|
|
!receipt ||
|
|
receipt.expense.createdById !== ctx.session.user.id
|
|
) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Receipt not found",
|
|
});
|
|
}
|
|
|
|
await deleteObject(receipt.storageKey);
|
|
await ctx.db
|
|
.delete(expenseReceipts)
|
|
.where(eq(expenseReceipts.id, input.id));
|
|
|
|
return { success: true };
|
|
}),
|
|
});
|