Files
beenvoice/src/server/api/routers/time-entries.ts
T
soconnorandCursor 1928084acb Add draft-only invoicing rules, send reminders, and time clock billing.
Restrict line item edits to draft invoices, auto-create drafts on clock-out,
and add sendReminderAt scheduling with dashboard due reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:11 -04:00

643 lines
20 KiB
TypeScript

import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
computeTrackedHours,
type ClockOutOutcome,
} from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
type Db = typeof db;
const createSchema = z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
startedAt: z.date(),
endedAt: z.date().optional(),
hours: z.number().min(0).optional(),
rate: z.number().min(0).optional(),
notes: z.string().max(500).optional().or(z.literal("")),
});
const updateSchema = createSchema.partial().extend({ id: z.string() });
async function resolveHourlyRate(
database: Db,
userId: string,
clientId: string | null,
explicitRate?: number | null,
): Promise<number | null> {
if (explicitRate != null && explicitRate > 0) return explicitRate;
if (!clientId) return explicitRate ?? null;
const client = await database.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
columns: { defaultHourlyRate: true },
});
return client?.defaultHourlyRate ?? explicitRate ?? null;
}
function computeHours(startedAt: Date, endedAt: Date): number {
return computeTrackedHours(startedAt, endedAt);
}
async function addEntryToInvoice(
database: Db,
invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] },
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
const amount = hours * rate;
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
await database.insert(invoiceItems).values({
invoiceId: invoice.id,
date,
description,
hours,
rate,
amount,
position: maxPosition + 1,
});
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
const newTotal = subtotal + (subtotal * invoice.taxRate) / 100;
await database
.update(invoices)
.set({ totalAmount: newTotal, updatedAt: new Date() })
.where(eq(invoices.id, invoice.id));
await database
.update(timeEntries)
.set({ invoiceId: invoice.id, updatedAt: new Date() })
.where(eq(timeEntries.id, entryId));
return {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
};
}
async function findOrCreateDraftInvoice(
database: Db,
userId: string,
clientId: string,
) {
const existing = await database.query.invoices.findFirst({
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
with: { items: true },
orderBy: [
desc(invoices.updatedAt),
desc(invoices.issueDate),
desc(invoices.invoiceNumber),
],
});
if (existing) return existing;
const client = await database.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
columns: { currency: true },
});
if (!client) return null;
const defaultBusiness = await database.query.businesses.findFirst({
where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
columns: { id: true },
});
const issueDate = new Date();
const [created] = await database
.insert(invoices)
.values({
invoiceNumber: generateInvoiceNumber(issueDate),
clientId,
businessId: defaultBusiness?.id ?? null,
issueDate,
dueDate: defaultDueDate(issueDate),
status: "draft",
totalAmount: 0,
taxRate: 0,
currency: client.currency,
createdById: userId,
})
.returning();
if (!created) return null;
return { ...created, items: [] as { amount: number; position: number }[] };
}
async function addEntryToLatestInvoice(
database: Db,
userId: string,
clientId: string,
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
async function addEntryToSpecificInvoice(
database: Db,
userId: string,
invoiceId: string,
entryId: string,
description: string,
hours: number,
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
with: { items: true },
});
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
export const timeEntriesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z
.object({
clientId: z.string().optional(),
from: z.date().optional(),
to: z.date().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [eq(timeEntries.createdById, ctx.session.user.id)];
if (input?.clientId) conditions.push(eq(timeEntries.clientId, input.clientId));
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
return ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true, invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } } },
orderBy: [desc(timeEntries.startedAt)],
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
with: { client: true },
});
if (!entry) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
return entry;
}),
getRunning: protectedProcedure.query(async ({ ctx }) => {
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
with: {
client: true,
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
},
});
return entry ?? null;
}),
clockIn: protectedProcedure
.input(
z.object({
description: z.string().max(500).default(""),
clientId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional(),
rate: z.number().min(0).optional(),
startedAt: z.date().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const running = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
});
if (running) {
throw new TRPCError({
code: "CONFLICT",
message: "A timer is already running. Stop it before clocking in.",
});
}
const clientId = input.clientId?.trim() ?? null;
let clientRecord: { defaultHourlyRate: number | null } | null = null;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
columns: { defaultHourlyRate: true },
});
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
clientRecord = found;
}
const invoiceId = input.invoiceId ?? null;
let resolvedClientId = clientId;
if (invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Selected invoice does not belong to this client",
});
}
resolvedClientId = resolvedClientId ?? invoice.clientId;
}
const startedAt = input.startedAt ?? new Date();
if (startedAt > new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
}
if (!clientRecord && resolvedClientId) {
const found = await ctx.db.query.clients.findFirst({
where: and(
eq(clients.id, resolvedClientId),
eq(clients.createdById, ctx.session.user.id),
),
columns: { defaultHourlyRate: true },
});
clientRecord = found ?? null;
}
const rate = await resolveHourlyRate(
ctx.db,
ctx.session.user.id,
resolvedClientId,
input.rate ?? clientRecord?.defaultHourlyRate,
);
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId: resolvedClientId,
invoiceId,
startedAt,
rate,
createdById: ctx.session.user.id,
})
.returning();
return entry;
}),
updateRunning: protectedProcedure
.input(
z.object({
description: z.string().max(500).optional(),
clientId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
),
});
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
}
const updates: {
description?: string;
clientId?: string | null;
invoiceId?: string | null;
rate?: number | null;
updatedAt: Date;
} = { updatedAt: new Date() };
if (input.description !== undefined) {
updates.description = input.description;
}
let resolvedClientId = entry.clientId;
if (input.clientId !== undefined) {
const clientId = input.clientId.trim() || null;
if (clientId) {
const found = await ctx.db.query.clients.findFirst({
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
});
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
}
resolvedClientId = clientId;
updates.clientId = clientId;
if (input.invoiceId === undefined) {
updates.invoiceId = null;
}
}
if (input.invoiceId !== undefined) {
const invoiceId = input.invoiceId.trim() || null;
if (invoiceId) {
const invoice = await ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Selected invoice does not belong to this client",
});
}
resolvedClientId = resolvedClientId ?? invoice.clientId;
updates.clientId = resolvedClientId;
}
updates.invoiceId = invoiceId;
}
if (input.rate !== undefined) {
updates.rate = input.rate;
} else if (input.clientId !== undefined && resolvedClientId) {
updates.rate = await resolveHourlyRate(
ctx.db,
ctx.session.user.id,
resolvedClientId,
entry.rate,
);
}
const [updated] = await ctx.db
.update(timeEntries)
.set(updates)
.where(eq(timeEntries.id, entry.id))
.returning();
if (!updated) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
}
return updated;
}),
clockOut: protectedProcedure
.input(
z.object({
id: z.string().optional(),
description: z.string().max(500).optional(),
}).optional(),
)
.mutation(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNull(timeEntries.endedAt),
];
if (input?.id) conditions.push(eq(timeEntries.id, input.id));
const entry = await ctx.db.query.timeEntries.findFirst({
where: and(...conditions),
});
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
}
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
const rate = entry.rate ?? 0;
const [updated] = await ctx.db
.update(timeEntries)
.set({ endedAt, hours, description, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id))
.returning();
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
let outcome: ClockOutOutcome = "zero_hours";
if (hours > 0) {
if (entry.invoiceId) {
linkedInvoice = await addEntryToSpecificInvoice(
ctx.db,
ctx.session.user.id,
entry.invoiceId,
updated.id,
description,
hours,
rate,
endedAt,
);
outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
} else if (entry.clientId) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
ctx.session.user.id,
entry.clientId,
updated.id,
description,
hours,
rate,
endedAt,
);
outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
} else {
outcome = "saved_no_client";
}
}
return {
entry: updated,
invoice: linkedInvoice,
outcome,
hours,
rate,
amount: hours * rate,
};
}),
create: protectedProcedure
.input(createSchema)
.mutation(async ({ ctx, input }) => {
const clientId = input.clientId?.trim() ?? null;
if (clientId) {
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" });
}
let hours = input.hours ?? null;
if (!hours && input.endedAt) {
hours = computeHours(input.startedAt, input.endedAt);
}
const [entry] = await ctx.db
.insert(timeEntries)
.values({
description: input.description,
clientId,
startedAt: input.startedAt,
endedAt: input.endedAt ?? null,
hours,
rate: input.rate ?? null,
notes: input.notes?.trim() ?? null,
createdById: ctx.session.user.id,
})
.returning();
if (!entry) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Create failed" });
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
if (clientId && hours && input.endedAt) {
linkedInvoice = await addEntryToLatestInvoice(
ctx.db,
ctx.session.user.id,
clientId,
entry.id,
input.description,
hours,
input.rate ?? 0,
input.endedAt,
);
}
return { entry, invoice: linkedInvoice };
}),
update: protectedProcedure
.input(updateSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
const clientId =
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
if (clientId) {
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" });
}
await ctx.db
.update(timeEntries)
.set({
...data,
clientId,
notes: data.notes?.trim() ?? null,
updatedAt: new Date(),
})
.where(eq(timeEntries.id, id));
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.timeEntries.findFirst({
where: and(
eq(timeEntries.id, input.id),
eq(timeEntries.createdById, ctx.session.user.id),
),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
return { success: true };
}),
getSummary: protectedProcedure
.input(
z.object({
from: z.date().optional(),
to: z.date().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
const conditions = [
eq(timeEntries.createdById, ctx.session.user.id),
isNotNull(timeEntries.endedAt),
];
if (input?.from) conditions.push(gte(timeEntries.startedAt, input.from));
if (input?.to) conditions.push(lte(timeEntries.startedAt, input.to));
const entries = await ctx.db.query.timeEntries.findMany({
where: and(...conditions),
with: { client: true },
});
const totalHours = entries.reduce((sum, e) => sum + (e.hours ?? 0), 0);
const totalEarnings = entries.reduce(
(sum, e) => sum + (e.hours ?? 0) * (e.rate ?? 0),
0,
);
return { totalHours, totalEarnings, count: entries.length };
}),
});