Archived
Sync time entries with invoice lines and harden auth for mobile.
Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { db } from "~/server/db";
|
||||
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
|
||||
import { resolveBillingDescription } from "~/lib/time-clock";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
function recalculateInvoiceTotal(
|
||||
items: { amount: number }[],
|
||||
taxRate: number,
|
||||
): number {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
return subtotal + (subtotal * taxRate) / 100;
|
||||
}
|
||||
|
||||
export async function findLinkedInvoiceItem(database: Db, timeEntryId: string) {
|
||||
return database.query.invoiceItems.findFirst({
|
||||
where: eq(invoiceItems.timeEntryId, timeEntryId),
|
||||
with: {
|
||||
invoice: {
|
||||
columns: { id: true, taxRate: true, status: true, createdById: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertInvoiceLineForTimeEntry(
|
||||
database: Db,
|
||||
input: {
|
||||
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;
|
||||
},
|
||||
) {
|
||||
const amount = input.hours * input.rate;
|
||||
const maxPosition = input.invoice.items.reduce(
|
||||
(m, item) => Math.max(m, item.position),
|
||||
-1,
|
||||
);
|
||||
|
||||
await database.insert(invoiceItems).values({
|
||||
invoiceId: input.invoice.id,
|
||||
date: input.date,
|
||||
description: input.description,
|
||||
hours: input.hours,
|
||||
rate: input.rate,
|
||||
amount,
|
||||
position: maxPosition + 1,
|
||||
timeEntryId: input.entryId,
|
||||
});
|
||||
|
||||
const subtotal =
|
||||
input.invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
|
||||
const newTotal = subtotal + (subtotal * input.invoice.taxRate) / 100;
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({ totalAmount: newTotal, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, input.invoice.id));
|
||||
|
||||
await database
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: input.invoice.id, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, input.entryId));
|
||||
|
||||
return {
|
||||
id: input.invoice.id,
|
||||
invoiceNumber: input.invoice.invoiceNumber,
|
||||
invoicePrefix: input.invoice.invoicePrefix ?? "#",
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncLinkedInvoiceItem(
|
||||
database: Db,
|
||||
entry: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
invoiceId: string | null;
|
||||
},
|
||||
) {
|
||||
const linked = await findLinkedInvoiceItem(database, entry.id);
|
||||
if (!linked?.invoice) return;
|
||||
|
||||
if (linked.invoice.status !== "draft") return;
|
||||
|
||||
const hours =
|
||||
entry.hours ??
|
||||
(entry.endedAt
|
||||
? Math.max(
|
||||
0,
|
||||
(entry.endedAt.getTime() - entry.startedAt.getTime()) / 3_600_000,
|
||||
)
|
||||
: null);
|
||||
|
||||
if (hours == null || hours <= 0) return;
|
||||
|
||||
const rate = entry.rate ?? 0;
|
||||
const amount = hours * rate;
|
||||
const description = resolveBillingDescription(entry.description ?? "");
|
||||
|
||||
await database
|
||||
.update(invoiceItems)
|
||||
.set({
|
||||
description,
|
||||
hours,
|
||||
rate,
|
||||
amount,
|
||||
date: entry.endedAt ?? entry.startedAt,
|
||||
})
|
||||
.where(eq(invoiceItems.id, linked.id));
|
||||
|
||||
const siblings = await database.query.invoiceItems.findMany({
|
||||
where: eq(invoiceItems.invoiceId, linked.invoiceId),
|
||||
columns: { amount: true },
|
||||
});
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({
|
||||
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, linked.invoiceId));
|
||||
}
|
||||
|
||||
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
|
||||
const linked = await findLinkedInvoiceItem(database, timeEntryId);
|
||||
if (!linked?.invoice) return;
|
||||
|
||||
await database.delete(invoiceItems).where(eq(invoiceItems.id, linked.id));
|
||||
|
||||
const siblings = await database.query.invoiceItems.findMany({
|
||||
where: eq(invoiceItems.invoiceId, linked.invoiceId),
|
||||
columns: { amount: true },
|
||||
});
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({
|
||||
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, linked.invoiceId));
|
||||
}
|
||||
|
||||
export async function relinkTimeEntryToInvoice(
|
||||
database: Db,
|
||||
userId: string,
|
||||
entry: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
clientId: string | null;
|
||||
},
|
||||
invoiceId: string | null,
|
||||
) {
|
||||
await removeLinkedInvoiceItem(database, entry.id);
|
||||
|
||||
if (!invoiceId || !entry.endedAt || !entry.hours || entry.hours <= 0) {
|
||||
await database
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: invoiceId ?? null, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, entry.id));
|
||||
return 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 insertInvoiceLineForTimeEntry(database, {
|
||||
invoice,
|
||||
entryId: entry.id,
|
||||
description: resolveBillingDescription(entry.description ?? ""),
|
||||
hours: entry.hours,
|
||||
rate: entry.rate ?? 0,
|
||||
date: entry.endedAt,
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
putObject,
|
||||
RECEIPT_MAX_BYTES,
|
||||
} from "~/lib/object-storage";
|
||||
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||
|
||||
export { EXPENSE_CATEGORIES };
|
||||
|
||||
@@ -431,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (
|
||||
!receipt ||
|
||||
receipt.expense.createdById !== ctx.session.user.id
|
||||
receipt?.expense.createdById !== ctx.session.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
@@ -447,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
suggestFromReceiptText: protectedProcedure
|
||||
.input(z.object({ text: z.string().min(1).max(20_000) }))
|
||||
.mutation(({ input }) => {
|
||||
const parsed = parseReceiptText(input.text);
|
||||
return {
|
||||
amount: parsed.amount,
|
||||
date: parsed.date,
|
||||
description: parsed.vendor,
|
||||
rawLines: parsed.rawLines,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { timeEntries, clients, invoices, businesses } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type ClockOutOutcome,
|
||||
} from "~/lib/time-clock";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import {
|
||||
insertInvoiceLineForTimeEntry,
|
||||
relinkTimeEntryToInvoice,
|
||||
removeLinkedInvoiceItem,
|
||||
syncLinkedInvoiceItem,
|
||||
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
|
||||
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,
|
||||
return insertInvoiceLineForTimeEntry(database, {
|
||||
invoice,
|
||||
entryId,
|
||||
description,
|
||||
hours,
|
||||
rate,
|
||||
amount,
|
||||
position: maxPosition + 1,
|
||||
date,
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -338,6 +321,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
clientId: z.string().optional().or(z.literal("")),
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
rate: z.number().min(0).optional(),
|
||||
startedAt: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -357,9 +341,20 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
clientId?: string | null;
|
||||
invoiceId?: string | null;
|
||||
rate?: number | null;
|
||||
startedAt?: Date;
|
||||
updatedAt: Date;
|
||||
} = { updatedAt: new Date() };
|
||||
|
||||
if (input.startedAt !== undefined) {
|
||||
if (input.startedAt > new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Start time cannot be in the future",
|
||||
});
|
||||
}
|
||||
updates.startedAt = input.startedAt;
|
||||
}
|
||||
|
||||
if (input.description !== undefined) {
|
||||
updates.description = input.description;
|
||||
}
|
||||
@@ -563,9 +558,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateSchema)
|
||||
.input(
|
||||
updateSchema.extend({
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
const { id, invoiceId: nextInvoiceId, ...data } = input;
|
||||
|
||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
@@ -575,6 +574,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
if (existing.endedAt == null) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Use updateRunning to edit the active timer",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId =
|
||||
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
|
||||
|
||||
@@ -585,16 +591,39 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
let hours = data.hours;
|
||||
const startedAt = data.startedAt ?? existing.startedAt;
|
||||
const endedAt = data.endedAt ?? existing.endedAt;
|
||||
|
||||
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) {
|
||||
hours = computeHours(startedAt, endedAt);
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({
|
||||
...data,
|
||||
clientId,
|
||||
notes: data.notes?.trim() ?? null,
|
||||
hours,
|
||||
notes: data.notes?.trim() ?? undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeEntries.id, id));
|
||||
|
||||
const updated = await ctx.db.query.timeEntries.findFirst({
|
||||
where: eq(timeEntries.id, id),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
||||
}
|
||||
|
||||
if (nextInvoiceId !== undefined) {
|
||||
await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null);
|
||||
} else {
|
||||
await syncLinkedInvoiceItem(ctx.db, updated);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -609,6 +638,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
await removeLinkedInvoiceItem(ctx.db, input.id);
|
||||
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
|
||||
rate: d.real().notNull(),
|
||||
amount: d.real().notNull(),
|
||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||
timeEntryId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
||||
fields: [invoiceItems.invoiceId],
|
||||
references: [invoices.id],
|
||||
}),
|
||||
timeEntry: one(timeEntries, {
|
||||
fields: [invoiceItems.timeEntryId],
|
||||
references: [timeEntries.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const expenses = createTable(
|
||||
|
||||
Reference in New Issue
Block a user