From 31151e7f39a2f6c2a53e73abd7adb3d9dde90cca Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sun, 28 Jun 2026 21:44:19 -0400 Subject: [PATCH] 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 --- drizzle/0023_invoice_item_time_entry.sql | 11 + drizzle/meta/_journal.json | 6 +- src/app/api/receipts/[id]/route.ts | 4 +- src/app/auth/signin/signin-form.tsx | 13 +- src/app/dashboard/expenses/page.tsx | 8 +- src/components/expenses/receipt-utils.ts | 3 +- .../providers/appearance-provider-synced.tsx | 2 +- .../time-clock/time-clock-panel.tsx | 90 +++++- .../time-clock/time-entries-history.tsx | 55 ++-- .../time-clock/time-entry-edit-dialog.tsx | 274 ++++++++++++++++++ src/components/time-clock/time-entry-list.tsx | 28 +- src/lib/auth-server.ts | 4 + src/lib/object-storage.ts | 4 +- src/lib/receipt-parse.ts | 63 ++++ src/server/api/lib/time-entry-invoice-sync.ts | 202 +++++++++++++ src/server/api/routers/expenses.ts | 16 +- src/server/api/routers/time-entries.ts | 92 ++++-- src/server/db/schema.ts | 7 + src/trpc/query-client.ts | 78 ++--- 19 files changed, 849 insertions(+), 111 deletions(-) create mode 100644 drizzle/0023_invoice_item_time_entry.sql create mode 100644 src/components/time-clock/time-entry-edit-dialog.tsx create mode 100644 src/lib/receipt-parse.ts create mode 100644 src/server/api/lib/time-entry-invoice-sync.ts diff --git a/drizzle/0023_invoice_item_time_entry.sql b/drizzle/0023_invoice_item_time_entry.sql new file mode 100644 index 0000000..8a488e3 --- /dev/null +++ b/drizzle/0023_invoice_item_time_entry.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 243fba6..76e9300 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] diff --git a/src/app/api/receipts/[id]/route.ts b/src/app/api/receipts/[id]/route.ts index 70e137c..125f9a8 100644 --- a/src/app/api/receipts/[id]/route.ts +++ b/src/app/api/receipts/[id]/route.ts @@ -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 }); } diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index 4f09924..6d3cb92 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -39,10 +39,17 @@ 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" - ? error.message - : "Invalid email or password", + rateLimited + ? "Too many sign-in attempts. Please wait a moment and try again." + : error.message && error.message !== "Required" + ? error.message + : "Invalid email or password", ); return; } diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index f310795..ca0d0b8 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -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; diff --git a/src/components/expenses/receipt-utils.ts b/src/components/expenses/receipt-utils.ts index 5b2908e..cd0030e 100644 --- a/src/components/expenses/receipt-utils.ts +++ b/src/components/expenses/receipt-utils.ts @@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise { } 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); }); } diff --git a/src/components/providers/appearance-provider-synced.tsx b/src/components/providers/appearance-provider-synced.tsx index 31d7380..436983e 100644 --- a/src/components/providers/appearance-provider-synced.tsx +++ b/src/components/providers/appearance-provider-synced.tsx @@ -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]); diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx index 4faeb6c..9eb6d39 100644 --- a/src/components/time-clock/time-clock-panel.tsx +++ b/src/components/time-clock/time-clock-panel.tsx @@ -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 ( + <> +
+ + setTitle(e.target.value)} + onBlur={() => onDescriptionCommit(title)} + placeholder="What are you working on?" + /> +
+ +
+ + { + 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} + /> +
+ + ); +} + export type TimeClockPanelProps = { defaultClientId?: string; defaultInvoiceId?: string; @@ -109,6 +166,7 @@ export function TimeClockPanel({ const [startMode, setStartMode] = useState("now"); const [pickedStart, setPickedStart] = useState(""); const [minutesAgo, setMinutesAgo] = useState("30"); + const [editEntryId, setEditEntryId] = useState(null); const intervalRef = useRef | 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({ ) : ( <> + +
@@ -621,7 +699,10 @@ export function TimeClockPanel({ {todayEntries?.some((e) => e.endedAt) ? ( - + setEditEntryId(entry.id)} + /> ) : (

No entries today.{" "} @@ -637,6 +718,13 @@ export function TimeClockPanel({ ) : null} + { + if (!open) setEditEntryId(null); + }} + />

); } diff --git a/src/components/time-clock/time-entries-history.tsx b/src/components/time-clock/time-entries-history.tsx index cd75289..736c566 100644 --- a/src/components/time-clock/time-entries-history.tsx +++ b/src/components/time-clock/time-entries-history.tsx @@ -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(null); const completedEntries = useMemo( () => (entries ?? []).filter((e) => e.endedAt), @@ -57,25 +60,35 @@ export function TimeEntriesHistory() { } return ( -
- {grouped.map((group) => ( - - - - {group.label} - - - - {group.entries.map((entry, index) => ( - - ))} - - - ))} -
+ <> +
+ {grouped.map((group) => ( + + + + {group.label} + + + + {group.entries.map((entry, index) => ( + setEditEntryId(item.id)} + /> + ))} + + + ))} +
+ { + if (!open) setEditEntryId(null); + }} + /> + ); } diff --git a/src/components/time-clock/time-entry-edit-dialog.tsx b/src/components/time-clock/time-entry-edit-dialog.tsx new file mode 100644 index 0000000..64292d0 --- /dev/null +++ b/src/components/time-clock/time-entry-edit-dialog.tsx @@ -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 ( + <> +
+
+ + setDescription(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + setStartedAt(e.target.value)} + /> +
+
+ + setEndedAt(e.target.value)} + /> +
+
+ + {hoursPreview != null ? ( +

+ Duration: {hoursPreview.toFixed(2)}h + {rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""} +

+ ) : null} +
+ + + +
+ + +
+
+ + ); +} + +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 ( + + + + Edit time entry + + + {entryQuery.isLoading ? ( +

Loading…

+ ) : entryQuery.data && entryId ? ( + onOpenChange(false)} + /> + ) : ( +

Time entry not found.

+ )} +
+
+ ); +} diff --git a/src/components/time-clock/time-entry-list.tsx b/src/components/time-clock/time-entry-list.tsx index c69ca24..dac69b5 100644 --- a/src/components/time-clock/time-entry-list.tsx +++ b/src/components/time-clock/time-entry-list.tsx @@ -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 ( + + ); + } + return (
{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} /> ))} diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index efc51dd..8ecb793 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -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=") ); diff --git a/src/lib/object-storage.ts b/src/lib/object-storage.ts index 9fd6169..f5383b4 100644 --- a/src/lib/object-storage.ts +++ b/src/lib/object-storage.ts @@ -73,9 +73,7 @@ async function withS3Diagnostics(operation: () => Promise): Promise { } async function getS3() { - if (!s3ModulePromise) { - s3ModulePromise = import("@aws-sdk/client-s3"); - } + s3ModulePromise ??= import("@aws-sdk/client-s3"); const mod = await s3ModulePromise; if (!s3Client) { logBareMinioEndpointHint(); diff --git a/src/lib/receipt-parse.ts b/src/lib/receipt-parse.ts new file mode 100644 index 0000000..a2f5e52 --- /dev/null +++ b/src/lib/receipt-parse.ts @@ -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, + }; +} diff --git a/src/server/api/lib/time-entry-invoice-sync.ts b/src/server/api/lib/time-entry-invoice-sync.ts new file mode 100644 index 0000000..31d65ca --- /dev/null +++ b/src/server/api/lib/time-entry-invoice-sync.ts @@ -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, + }); +} diff --git a/src/server/api/routers/expenses.ts b/src/server/api/routers/expenses.ts index 0a6cec1..8b9116e 100644 --- a/src/server/api/routers/expenses.ts +++ b/src/server/api/routers/expenses.ts @@ -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, + }; + }), }); diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 222cfe3..2d46d25 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -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 }; }), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index a69444b..d2c9c8d 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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( diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts index 714de43..27eb19c 100644 --- a/src/trpc/query-client.ts +++ b/src/trpc/query-client.ts @@ -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,