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 "breakpoints": true
}, },
{ {
"idx": 22, "idx": 23,
"version": "7", "version": "7",
"when": 1782100000000, "when": 1782200000000,
"tag": "0022_expense_business_receipts", "tag": "0023_invoice_item_time_entry",
"breakpoints": true "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 { eq } from "drizzle-orm";
import { getOptionalServerSession } from "~/lib/auth-server"; import { getOptionalServerSession } from "~/lib/auth-server";
import { getObject } from "~/lib/object-storage"; import { getObject } from "~/lib/object-storage";
@@ -20,7 +20,7 @@ export async function GET(
with: { expense: true }, 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 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
+8 -1
View File
@@ -39,8 +39,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
setLoading(false); setLoading(false);
if (error) { if (error) {
const message = error.message?.toLowerCase() ?? "";
const rateLimited =
error.status === 429 ||
message.includes("too many") ||
message.includes("rate limit");
toast.error( 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 ? error.message
: "Invalid email or password", : "Invalid email or password",
); );
+1 -7
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
@@ -116,12 +116,6 @@ export default function ExpensesPage() {
[businesses], [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({ const create = api.expenses.create.useMutation({
onSuccess: (expense) => { onSuccess: (expense) => {
if (!expense) return; if (!expense) return;
+2 -1
View File
@@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise<string> {
} }
resolve(base64); 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); reader.readAsDataURL(file);
}); });
} }
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
if (!serverColorMode?.colorMode) return; if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return; if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode); setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true; serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]); }, [serverColorMode?.colorMode]);
+89 -1
View File
@@ -37,11 +37,68 @@ import {
} from "~/lib/time-clock"; } from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display"; import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list"; import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4; const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago"; 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 = { export type TimeClockPanelProps = {
defaultClientId?: string; defaultClientId?: string;
defaultInvoiceId?: string; defaultInvoiceId?: string;
@@ -109,6 +166,7 @@ export function TimeClockPanel({
const [startMode, setStartMode] = useState<StartMode>("now"); const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState(""); const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30"); const [minutesAgo, setMinutesAgo] = useState("30");
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId; const draftClientId = running ? (running.clientId ?? "") : clientId;
@@ -185,6 +243,18 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message), 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({ const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
const message = describeClockOutOutcome({ 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"> <div className="space-y-2">
<Label>Client</Label> <Label>Client</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -621,7 +699,10 @@ export function TimeClockPanel({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{todayEntries?.some((e) => e.endedAt) ? ( {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"> <p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "} No entries today.{" "}
@@ -637,6 +718,13 @@ export function TimeClockPanel({
</Card> </Card>
) : null} ) : null}
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react"; import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display"; import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list"; 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() { export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery(); const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo( const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt), () => (entries ?? []).filter((e) => e.endedAt),
@@ -57,6 +60,7 @@ export function TimeEntriesHistory() {
} }
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
{grouped.map((group) => ( {grouped.map((group) => (
<Card key={group.dateKey}> <Card key={group.dateKey}>
@@ -71,11 +75,20 @@ export function TimeEntriesHistory() {
key={entry.id} key={entry.id}
entry={entry} entry={entry}
isLast={index === group.entries.length - 1} isLast={index === group.entries.length - 1}
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
/> />
))} ))}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </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({ export function TimeEntryRow({
entry, entry,
isLast, isLast,
onEdit,
}: { }: {
entry: TimeEntryListItem; entry: TimeEntryListItem;
isLast?: boolean; isLast?: boolean;
onEdit?: (entry: TimeEntryListItem) => void;
}) { }) {
const href = entryHref(entry); const href = onEdit ? null : entryHref(entry);
const rowClassName = cn( const rowClassName = cn(
"flex items-start justify-between gap-4 py-3", "flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b", !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 ( return (
<div className={rowClassName}> <div className={rowClassName}>
{content} {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); const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null; if (completed.length === 0) return null;
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
key={entry.id} key={entry.id}
entry={entry} entry={entry}
isLast={index === completed.length - 1} 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 { export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? ""; const cookie = headers.get("cookie") ?? "";
if (!cookie.trim()) return false;
return ( return (
cookie.includes("session_token=") ||
cookie.includes("session_data=") ||
cookie.includes("better-auth.session_token=") || cookie.includes("better-auth.session_token=") ||
cookie.includes("__Secure-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() { async function getS3() {
if (!s3ModulePromise) { s3ModulePromise ??= import("@aws-sdk/client-s3");
s3ModulePromise = import("@aws-sdk/client-s3");
}
const mod = await s3ModulePromise; const mod = await s3ModulePromise;
if (!s3Client) { if (!s3Client) {
logBareMinioEndpointHint(); 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, putObject,
RECEIPT_MAX_BYTES, RECEIPT_MAX_BYTES,
} from "~/lib/object-storage"; } from "~/lib/object-storage";
import { parseReceiptText } from "~/lib/receipt-parse";
export { EXPENSE_CATEGORIES }; export { EXPENSE_CATEGORIES };
@@ -431,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
}); });
if ( if (
!receipt || receipt?.expense.createdById !== ctx.session.user.id
receipt.expense.createdById !== ctx.session.user.id
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -447,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
return { success: true }; 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 { z } from "zod";
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm"; import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; 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 { TRPCError } from "@trpc/server";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
import { import {
@@ -10,6 +10,12 @@ import {
type ClockOutOutcome, type ClockOutOutcome,
} from "~/lib/time-clock"; } from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import {
insertInvoiceLineForTimeEntry,
relinkTimeEntryToInvoice,
removeLinkedInvoiceItem,
syncLinkedInvoiceItem,
} from "~/server/api/lib/time-entry-invoice-sync";
type Db = typeof db; type Db = typeof db;
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
rate: number, rate: number,
date: Date, date: Date,
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> { ): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
const amount = hours * rate; return insertInvoiceLineForTimeEntry(database, {
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1); invoice,
entryId,
await database.insert(invoiceItems).values({
invoiceId: invoice.id,
date,
description, description,
hours, hours,
rate, rate,
amount, date,
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( async function findOrCreateDraftInvoice(
@@ -338,6 +321,7 @@ export const timeEntriesRouter = createTRPCRouter({
clientId: z.string().optional().or(z.literal("")), clientId: z.string().optional().or(z.literal("")),
invoiceId: z.string().optional().or(z.literal("")), invoiceId: z.string().optional().or(z.literal("")),
rate: z.number().min(0).optional(), rate: z.number().min(0).optional(),
startedAt: z.date().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -357,9 +341,20 @@ export const timeEntriesRouter = createTRPCRouter({
clientId?: string | null; clientId?: string | null;
invoiceId?: string | null; invoiceId?: string | null;
rate?: number | null; rate?: number | null;
startedAt?: Date;
updatedAt: Date; updatedAt: Date;
} = { updatedAt: new 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) { if (input.description !== undefined) {
updates.description = input.description; updates.description = input.description;
} }
@@ -563,9 +558,13 @@ export const timeEntriesRouter = createTRPCRouter({
}), }),
update: protectedProcedure update: protectedProcedure
.input(updateSchema) .input(
updateSchema.extend({
invoiceId: z.string().optional().or(z.literal("")),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, ...data } = input; const { id, invoiceId: nextInvoiceId, ...data } = input;
const existing = await ctx.db.query.timeEntries.findFirst({ const existing = await ctx.db.query.timeEntries.findFirst({
where: and( 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) 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 = const clientId =
data.clientId !== undefined ? data.clientId?.trim() || null : undefined; 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" }); 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 await ctx.db
.update(timeEntries) .update(timeEntries)
.set({ .set({
...data, ...data,
clientId, clientId,
notes: data.notes?.trim() ?? null, hours,
notes: data.notes?.trim() ?? undefined,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(timeEntries.id, id)); .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 }; return { success: true };
}), }),
@@ -609,6 +638,7 @@ export const timeEntriesRouter = createTRPCRouter({
}); });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" }); 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)); await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
return { success: true }; return { success: true };
}), }),
+7
View File
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
rate: d.real().notNull(), rate: d.real().notNull(),
amount: d.real().notNull(), amount: d.real().notNull(),
position: d.integer().notNull().default(0), // NEW: position for ordering position: d.integer().notNull().default(0), // NEW: position for ordering
timeEntryId: d
.varchar({ length: 255 })
.references(() => timeEntries.id, { onDelete: "set null" }),
createdAt: d createdAt: d
.timestamp() .timestamp()
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
fields: [invoiceItems.invoiceId], fields: [invoiceItems.invoiceId],
references: [invoices.id], references: [invoices.id],
}), }),
timeEntry: one(timeEntries, {
fields: [invoiceItems.timeEntryId],
references: [timeEntries.id],
}),
})); }));
export const expenses = createTable( export const expenses = createTable(
+44 -34
View File
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
import { toast } from "sonner"; import { toast } from "sonner";
import SuperJSON from "superjson"; 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 = () => export const createQueryClient = () =>
new QueryClient({ new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({ onError: handleQueryError }),
onError: (error) => { mutationCache: new MutationCache({ onError: handleQueryError }),
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";
}
}
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000, 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: { dehydrate: {
serializeData: SuperJSON.serialize, serializeData: SuperJSON.serialize,