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:
2026-06-28 21:44:19 -04:00
co-authored by Cursor
parent c267b3e1c8
commit 31151e7f39
19 changed files with 849 additions and 111 deletions
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
) THEN
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
+3 -3
View File
@@ -157,10 +157,10 @@
"breakpoints": true
},
{
"idx": 22,
"idx": 23,
"version": "7",
"when": 1782100000000,
"tag": "0022_expense_business_receipts",
"when": 1782200000000,
"tag": "0023_invoice_item_time_entry",
"breakpoints": true
}
]
+2 -2
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage";
@@ -20,7 +20,7 @@ export async function GET(
with: { expense: true },
});
if (!receipt || receipt.expense.createdById !== session.user.id) {
if (receipt?.expense.createdById !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
+8 -1
View File
@@ -39,8 +39,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false);
if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error(
error.message && error.message !== "Required"
rateLimited
? "Too many sign-in attempts. Please wait a moment and try again."
: error.message && error.message !== "Required"
? error.message
: "Invalid email or password",
);
+1 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
@@ -116,12 +116,6 @@ export default function ExpensesPage() {
[businesses],
);
useEffect(() => {
if (!open || dialogMode !== "create" || !defaultBusinessId || form.businessId)
return;
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
}, [open, dialogMode, defaultBusinessId, form.businessId]);
const create = api.expenses.create.useMutation({
onSuccess: (expense) => {
if (!expense) return;
+2 -1
View File
@@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise<string> {
}
resolve(base64);
};
reader.onerror = () => reject(reader.error);
reader.onerror = () =>
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]);
+89 -1
View File
@@ -37,11 +37,68 @@ import {
} from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
function RunningTextFields({
running,
updateRunningPending,
onDescriptionCommit,
onStartedAtCommit,
}: {
running: { id: string; description: string | null; startedAt: Date };
updateRunningPending: boolean;
onDescriptionCommit: (description: string) => void;
onStartedAtCommit: (startedAt: Date) => void;
}) {
const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt),
);
return (
<>
<div className="space-y-2">
<Label htmlFor="clock-running-title">What are you working on?</Label>
<Input
id="clock-running-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => onDescriptionCommit(title)}
placeholder="What are you working on?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clock-running-start">Started at</Label>
<Input
id="clock-running-start"
type="datetime-local"
value={runningStartedAt}
onChange={(e) => {
const value = e.target.value;
setRunningStartedAt(value);
if (!value) return;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
onStartedAtCommit(parsed);
}}
disabled={updateRunningPending}
/>
</div>
</>
);
}
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
@@ -109,6 +166,7 @@ export function TimeClockPanel({
const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30");
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId;
@@ -185,6 +243,18 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message),
});
function handleRunningDescriptionCommit(nextTitle: string) {
if (!running) return;
const next = resolveClockDescription(nextTitle);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtCommit(parsed: Date) {
if (!running) return;
updateRunning.mutate({ startedAt: parsed });
}
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => {
const message = describeClockOutOutcome({
@@ -496,6 +566,14 @@ export function TimeClockPanel({
</>
) : (
<>
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
@@ -621,7 +699,10 @@ export function TimeClockPanel({
</CardHeader>
<CardContent>
{todayEntries?.some((e) => e.endedAt) ? (
<TimeEntryList entries={todayEntries} />
<TimeEntryList
entries={todayEntries}
onEdit={(entry) => setEditEntryId(entry.id)}
/>
) : (
<p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "}
@@ -637,6 +718,13 @@ export function TimeClockPanel({
</Card>
) : null}
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
@@ -57,6 +60,7 @@ export function TimeEntriesHistory() {
}
return (
<>
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
@@ -71,11 +75,20 @@ export function TimeEntriesHistory() {
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
/>
))}
</CardContent>
</Card>
))}
</div>
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react";
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
export type TimeEntryEditDialogProps = {
entryId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type TimeEntryEditFormProps = {
entry: TimeEntry;
entryId: string;
clients: RouterOutputs["clients"]["getAll"];
onClose: () => void;
};
function TimeEntryEditForm({
entry,
entryId,
clients,
onClose,
}: TimeEntryEditFormProps) {
const utils = api.useUtils();
const [description, setDescription] = useState(entry.description ?? "");
const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
const [rate, setRate] = useState(entry.rate ?? 0);
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
const [endedAt, setEndedAt] = useState(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
);
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: Boolean(clientId) },
);
const hoursPreview = useMemo(() => {
if (!startedAt || !endedAt) return null;
const start = new Date(startedAt);
const end = new Date(endedAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
toast.success("Time entry updated");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
toast.success("Time entry deleted");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
function handleSave() {
const start = new Date(startedAt);
const end = endedAt ? new Date(endedAt) : undefined;
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
toast.error("Invalid start or end time");
return;
}
if (end && end <= start) {
toast.error("End time must be after start time");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate,
startedAt: start,
endedAt: end,
hours: hoursPreview ?? undefined,
});
}
return (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entry-description">Description</Label>
<Input
id="entry-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<Select
value={clientId || "__none__"}
onValueChange={(v) => {
setClientId(v === "__none__" ? "" : v);
setInvoiceId("");
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue placeholder="Not on invoice" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Not on invoice</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="entry-start">Started</Label>
<Input
id="entry-start"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="entry-end">Ended</Label>
<Input
id="entry-end"
type="datetime-local"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
/>
</div>
</div>
{hoursPreview != null ? (
<p className="text-muted-foreground text-sm">
Duration: {hoursPreview.toFixed(2)}h
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
</p>
) : null}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
type="button"
variant="destructive"
disabled={deleteEntry.isPending}
onClick={() => deleteEntry.mutate({ id: entryId })}
>
Delete
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
Save
</Button>
</div>
</DialogFooter>
</>
);
}
export function TimeEntryEditDialog({
entryId,
open,
onOpenChange,
}: TimeEntryEditDialogProps) {
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: Boolean(entryId) && open },
);
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit time entry</DialogTitle>
</DialogHeader>
{entryQuery.isLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : entryQuery.data && entryId ? (
<TimeEntryEditForm
key={entryQuery.data.id}
entry={entryQuery.data}
entryId={entryId}
clients={clients}
onClose={() => onOpenChange(false)}
/>
) : (
<p className="text-muted-foreground text-sm">Time entry not found.</p>
)}
</DialogContent>
</Dialog>
);
}
+26 -2
View File
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
export function TimeEntryRow({
entry,
isLast,
onEdit,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const href = entryHref(entry);
const href = onEdit ? null : entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
@@ -50,6 +52,21 @@ export function TimeEntryRow({
);
}
if (onEdit) {
return (
<button
type="button"
onClick={() => onEdit(entry)}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</button>
);
}
return (
<div className={rowClassName}>
{content}
@@ -57,7 +74,13 @@ export function TimeEntryRow({
);
}
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
export function TimeEntryList({
entries,
onEdit,
}: {
entries: TimeEntryListItem[];
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
onEdit={onEdit}
/>
))}
</>
+4
View File
@@ -3,7 +3,11 @@ import { auth } from "~/lib/auth";
export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? "";
if (!cookie.trim()) return false;
return (
cookie.includes("session_token=") ||
cookie.includes("session_data=") ||
cookie.includes("better-auth.session_token=") ||
cookie.includes("__Secure-better-auth.session_token=")
);
+1 -3
View File
@@ -73,9 +73,7 @@ async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
}
async function getS3() {
if (!s3ModulePromise) {
s3ModulePromise = import("@aws-sdk/client-s3");
}
s3ModulePromise ??= import("@aws-sdk/client-s3");
const mod = await s3ModulePromise;
if (!s3Client) {
logBareMinioEndpointHint();
+63
View File
@@ -0,0 +1,63 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
vendor: string | null;
rawLines: string[];
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
/** Heuristic receipt field extraction from OCR or pasted text. */
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
vendor: parseVendor(rawLines),
rawLines,
};
}
@@ -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,
});
}
+14 -2
View File
@@ -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,
};
}),
});
+61 -31
View File
@@ -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 };
}),
+7
View File
@@ -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(
+44 -34
View File
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
import { toast } from "sonner";
import SuperJSON from "superjson";
function isUnauthorized(error: unknown): boolean {
return (
error instanceof TRPCClientError &&
error.data != null &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
);
}
function isRateLimited(error: unknown): boolean {
if (!(error instanceof TRPCClientError)) return false;
if (error.data != null && typeof error.data === "object" && "code" in error.data) {
if ((error.data as { code: string }).code === "TOO_MANY_REQUESTS") return true;
}
const message = error.message.toLowerCase();
return message.includes("too many") || message.includes("rate limit");
}
function handleQueryError(error: unknown) {
if (isRateLimited(error)) {
toast.error("Too many requests. Please wait a moment and try again.");
return;
}
if (isUnauthorized(error)) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
}
export const createQueryClient = () =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (
error instanceof TRPCClientError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (
error instanceof TRPCClientError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
(error.data as { code: string }).code === "UNAUTHORIZED"
) {
toast.error("Please sign in to continue");
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
},
}),
queryCache: new QueryCache({ onError: handleQueryError }),
mutationCache: new MutationCache({ onError: handleQueryError }),
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
retry: (failureCount, error) => {
if (isUnauthorized(error) || isRateLimited(error)) return false;
return failureCount < 1;
},
},
mutations: {
retry: (failureCount, error) => {
if (isRateLimited(error)) return false;
return failureCount < 1;
},
},
dehydrate: {
serializeData: SuperJSON.serialize,