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>
This commit is contained in:
2026-06-22 16:06:11 -04:00
co-authored by Cursor
parent 4cd8ad3c4c
commit 1928084acb
12 changed files with 311 additions and 85 deletions
+22 -1
View File
@@ -1,6 +1,6 @@
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema";
import { eq, desc } from "drizzle-orm";
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
@@ -118,6 +118,26 @@ export const dashboardRouter = createTRPCRouter({
},
});
const sendReminderDue = await ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
},
with: {
client: { columns: { name: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
return {
totalRevenue,
pendingAmount,
@@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({
: 0,
revenueChartData,
recentInvoices,
sendReminderDue,
};
}),
});
+20 -2
View File
@@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
sendReminderAt: z.date().nullable().optional(),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
});
@@ -155,13 +156,13 @@ export const invoicesRouter = createTRPCRouter({
}
}),
/** Draft and sent invoices available for time-clock billing. */
/** Draft invoices available for time-clock billing. */
getBillable: protectedProcedure
.input(z.object({ clientId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const conditions = [
eq(invoices.createdById, ctx.session.user.id),
inArray(invoices.status, ["draft", "sent"]),
eq(invoices.status, "draft"),
];
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
@@ -418,6 +419,23 @@ export const invoicesRouter = createTRPCRouter({
});
}
if (items && existingInvoice.status !== "draft") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Line items can only be edited on draft invoices",
});
}
if (
cleanInvoiceData.sendReminderAt !== undefined &&
existingInvoice.status !== "draft"
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Send reminders can only be set on draft invoices",
});
}
// If business is being updated, verify it belongs to user
if (
cleanInvoiceData.businessId &&
+63 -21
View File
@@ -1,13 +1,14 @@
import { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
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;
@@ -86,6 +87,60 @@ async function addEntryToInvoice(
};
}
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,
@@ -96,20 +151,7 @@ async function addEntryToLatestInvoice(
rate: number,
date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
const invoice = await database.query.invoices.findFirst({
where: and(
eq(invoices.clientId, clientId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
),
with: { items: true },
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
});
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
if (!invoice) return null;
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
}
@@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice(
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, userId),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
eq(invoices.status, "draft"),
),
with: { items: true },
});
@@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found or not open for time tracking",
message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
@@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({
where: and(
eq(invoices.id, invoiceId),
eq(invoices.createdById, ctx.session.user.id),
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
eq(invoices.status, "draft"),
),
columns: { id: true, clientId: true },
});
if (!invoice) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found or not open for time tracking",
message: "Only draft invoices accept new time entries",
});
}
if (resolvedClientId && invoice.clientId !== resolvedClientId) {