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:
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user