diff --git a/README.md b/README.md
index 99965146..9c56d5bd 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,17 @@ For Cloudflare Workers hosting with OpenNext, follow the [deployment guide](docs
yarn dev
```
+## Financial Dashboard
+
+Open **Dashboard** in the sidebar to view local invoices and, when signed in, your saved server invoices. Select a currency to see collected, outstanding, and overdue totals, six- or twelve-month trends, and the top five clients by all-time billed amount. Currencies are never combined or converted.
+
+- Collected includes invoices marked `success`. Monthly collections use the recorded payment date; paid invoices without one remain in all-time totals.
+- Outstanding includes `pending`, `error`, and `expired` invoices. Overdue is the subset past its due date or marked `expired`; an invoice due today is not overdue.
+- Refunded invoices are excluded from amounts. Monthly billed amounts use the invoice date, and clients are grouped by normalized name and address.
+- Amounts use decimal arithmetic and round each invoice to two decimals, matching invoice display precision. Partial payments and a payment ledger are not supported.
+
+The dashboard uses existing invoice fields and requires **no new database migration**, environment variable, or dependency.
+
## 🛠️ Tech Stack
### Core Framework
diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx
new file mode 100644
index 00000000..881e86eb
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx
@@ -0,0 +1,153 @@
+import { formatDashboardAmount, type DashboardCurrency } from "@/lib/dashboard/financial-dashboard";
+import { ArrowDownLeft, Clock3, CircleAlert } from "lucide-react";
+
+interface DashboardPanelProps {
+ data: DashboardCurrency;
+}
+
+function DashboardOverview({ data }: DashboardPanelProps) {
+ const metrics = [
+ {
+ title: "Collected",
+ amount: data.collected,
+ count: data.collectedCount,
+ detail: "Invoices marked as paid",
+ icon: ArrowDownLeft,
+ color: "text-emerald-700 dark:text-emerald-400",
+ background: "bg-emerald-500/10",
+ },
+ {
+ title: "Outstanding",
+ amount: data.outstanding,
+ count: data.outstandingCount,
+ detail: "All unpaid invoices, including overdue",
+ icon: Clock3,
+ color: "text-foreground",
+ background: "bg-muted",
+ },
+ {
+ title: "Overdue",
+ amount: data.overdue,
+ count: data.overdueCount,
+ detail: "Past due date or marked expired",
+ icon: CircleAlert,
+ color: "text-amber-700 dark:text-amber-400",
+ background: "bg-amber-500/10",
+ },
+ ];
+
+ return (
+
+
+
+ All-time overview
+
+ {data.currency}
+
+
+ {metrics.map(({ title, amount, count, detail, icon: Icon, color, background }) => (
+
+
+
{title}
+
+
+
+
+
+ {formatDashboardAmount(amount, data.currency)}
+
+
+ {count} {count === 1 ? "invoice" : "invoices"}
+
+
{detail}
+
+ ))}
+
+
+ Pending, error, and expired invoices are outstanding. Overdue is included in outstanding. Refunded invoices are
+ excluded from amounts.
+
+
+ );
+}
+
+function TopClients({ data }: DashboardPanelProps) {
+ return (
+
+
+
+
+ Top clients
+
+
+ Top 5 by all-time billed amount in {data.currency}. Matched by name and address.
+
+
+
All time
+
+ {data.topClients.length ? (
+
+
+ Top clients in {data.currency}, ranked by total billed
+
+
+
+ Client
+
+
+ Invoices
+
+
+ Billed
+
+
+ Collected
+
+
+ Outstanding
+
+
+
+
+ {data.topClients.map((client, index) => (
+
+
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
{client.name}
+ {client.address ? (
+
+ {client.address}
+
+ ) : null}
+
+
+
+ {client.invoiceCount}
+
+ {formatDashboardAmount(client.billed, data.currency)}
+
+
+ {formatDashboardAmount(client.collected, data.currency)}
+
+
+ {formatDashboardAmount(client.outstanding, data.currency)}
+
+
+ ))}
+
+
+
+ ) : (
+
+ No non-refunded invoices to rank in this currency yet.
+
+ )}
+
+ );
+}
+
+export { DashboardOverview, TopClients };
diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx
new file mode 100644
index 00000000..1ffb6bfe
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { buildFinancialDashboard, mergeDashboardInvoices } from "@/lib/dashboard/financial-dashboard";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { ArrowUpRight, Plus, RefreshCw, Wallet } from "lucide-react";
+import { DashboardOverview, TopClients } from "./dashboard-panels";
+import { getAllInvoices } from "@/lib/indexdb-queries/invoice";
+import { MonthlyTrend } from "./monthly-trend.client";
+import { useQuery } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import { useSession } from "@/lib/client-auth";
+import { useTRPCClient } from "@/trpc/client";
+import { LINKS } from "@/constants/links";
+import { useMemo, useState } from "react";
+import Link from "next/link";
+
+function FinancialDashboard() {
+ const trpcClient = useTRPCClient();
+ const { data: session, isPending: isSessionPending, error: sessionError, refetch: refetchSession } = useSession();
+ const [selectedCurrency, setSelectedCurrency] = useState("");
+ const [months, setMonths] = useState<6 | 12>(6);
+
+ const localInvoices = useQuery({
+ queryKey: ["idb-invoices"],
+ queryFn: getAllInvoices,
+ staleTime: 0,
+ });
+ const serverInvoices = useQuery({
+ // Scope cached financial data to the signed-in account.
+ queryKey: ["dashboard-invoices", session?.user.id],
+ queryFn: () => trpcClient.invoice.dashboard.query(),
+ enabled: !!session?.user && !isSessionPending && !sessionError,
+ staleTime: 0,
+ });
+
+ const serverData = session?.user && !sessionError ? serverInvoices.data : undefined;
+ const today = new Date().setHours(0, 0, 0, 0);
+ const dashboard = useMemo(
+ () =>
+ buildFinancialDashboard(mergeDashboardInvoices(localInvoices.data ?? [], serverData ?? []), {
+ months,
+ now: new Date(today),
+ }),
+ // Refresh the date-sensitive overdue calculation when the page rerenders on a new day.
+ [localInvoices.data, serverData, months, today],
+ );
+ const currency = dashboard.currencies.find((entry) => entry.currency === selectedCurrency) ?? dashboard.currencies[0];
+ const isLoading = isSessionPending || localInvoices.isLoading || (!!session?.user && serverInvoices.isLoading);
+ const isRefreshing = localInvoices.isFetching || (!!session?.user && serverInvoices.isFetching);
+ const hasError = localInvoices.isError || (!!session?.user && serverInvoices.isError) || !!sessionError;
+
+ function refresh() {
+ void localInvoices.refetch();
+ if (sessionError) void refetchSession();
+ else if (session?.user) void serverInvoices.refetch();
+ }
+
+ return (
+
+
+
+
Financial overview
+
Dashboard
+
+ A clear view of what’s paid, what’s due, and who you work with.
+
+
+
+
+
+ {isRefreshing ? "Refreshing…" : "Refresh"}
+
+
+
+
+ Create invoice
+
+
+
+
+
+ {hasError ? (
+
+ Some invoice data is unavailable
+
+
+ {sessionError ? "We couldn’t check your sign-in status. " : ""}
+ {localInvoices.isError ? "Invoices on this device couldn’t be loaded. " : ""}
+ {session?.user && serverInvoices.isError ? "Saved server invoices couldn’t be refreshed. " : ""}
+ Figures below may be incomplete or out of date. Use Refresh to try again.
+
+
+
+ ) : null}
+
+ {isLoading ? (
+
+
Loading your financial overview…
+
+ ) : !currency ? (
+
+
+
+
+
+ {hasError ? "Your overview is unavailable" : "Your finances, at a glance"}
+
+
+ {hasError
+ ? "Refresh to load your invoices and see your financial overview."
+ : "Create your first invoice to start tracking collections, outstanding amounts, and your top clients."}
+
+ {!hasError ? (
+
+
+ Create your first invoice
+
+
+
+ ) : null}
+
+ ) : (
+ <>
+
+
+
+ Currency
+
+ setSelectedCurrency(event.target.value)}
+ className="border-input bg-background focus-visible:ring-ring h-9 min-w-44 rounded-md border px-3 text-sm outline-none focus-visible:ring-2"
+ >
+ {dashboard.currencies.map((entry) => (
+
+ {entry.currency} · {entry.invoiceCount} {entry.invoiceCount === 1 ? "invoice" : "invoices"}
+
+ ))}
+
+
+
+ {dashboard.invoiceCount} {dashboard.invoiceCount === 1 ? "invoice" : "invoices"} across{" "}
+ {dashboard.currencies.length} {dashboard.currencies.length === 1 ? "currency" : "currencies"}
+
+ Each currency is reported separately. No conversion or combined totals.
+
+
+
+
+
+
+
+
+
+ Monthly trends
+
+
+ Billed by invoice date. Collected by payment date. Amounts in {currency.currency}.
+
+
+
+
+ Trend period
+
+ setMonths(event.target.value === "12" ? 12 : 6)}
+ className="border-input bg-background focus-visible:ring-ring h-8 rounded-md border px-2 text-xs outline-none focus-visible:ring-2"
+ >
+ Last 6 months
+ Last 12 months
+
+
+
+
+ {currency.undatedCollectedCount > 0 ? (
+
+ {currency.undatedCollectedCount} paid{" "}
+ {currency.undatedCollectedCount === 1 ? "invoice has" : "invoices have"} no payment date. Included in
+ collected totals, excluded from monthly collections.
+
+ ) : null}
+
+
+
+
+
+
+ {session?.user
+ ? "Includes invoices on this device and in your account."
+ : "Showing invoices on this device. Sign in to include your saved server invoices."}
+
+
+ Manage invoices
+
+
+
+ >
+ )}
+
+ );
+}
+
+export { FinancialDashboard };
diff --git a/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx b/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx
new file mode 100644
index 00000000..3463bfca
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { formatDashboardAmount, type DashboardCurrency } from "@/lib/dashboard/financial-dashboard";
+import Decimal from "decimal.js";
+import { useId } from "react";
+
+function monthLabel(month: string, short = false) {
+ const [year, monthNumber] = month.split("-").map(Number);
+ return new Intl.DateTimeFormat("en-US", {
+ month: short ? "short" : "long",
+ year: short ? "2-digit" : "numeric",
+ }).format(new Date(year, monthNumber - 1, 1));
+}
+
+function compactAmount(value: Decimal) {
+ for (const [divisor, suffix] of [
+ ["1000000000000", "T"],
+ ["1000000000", "B"],
+ ["1000000", "M"],
+ ["1000", "K"],
+ ]) {
+ if (value.abs().gte(divisor)) return `${value.div(divisor).toDecimalPlaces(1).toString()}${suffix}`;
+ }
+ return value.toDecimalPlaces(2).toString();
+}
+
+function MonthlyTrend({ data }: { data: DashboardCurrency }) {
+ const chartId = useId();
+ const values = data.monthly.flatMap((month) => [new Decimal(month.billed), new Decimal(month.collected)]);
+ const maximum = Decimal.max(0, ...values);
+ const minimum = Decimal.min(0, ...values);
+ const range = maximum.minus(minimum);
+ const hasActivity = !range.isZero();
+ const scale = hasActivity ? range : new Decimal(1);
+ const chartTop = 16;
+ const chartHeight = 200;
+ const chartLeft = 64;
+ const plotWidth = 736;
+ const step = plotWidth / data.monthly.length;
+ const barWidth = Math.min(26, step * 0.28);
+
+ function yPosition(value: Decimal) {
+ // Only chart coordinates use JS numbers. Money stays in Decimal/string form.
+ return chartTop + maximum.minus(value).div(scale).toNumber() * chartHeight;
+ }
+
+ const zeroY = hasActivity ? yPosition(new Decimal(0)) : chartTop + chartHeight;
+
+ return (
+
+
+
+
+ Billed
+
+
+
+ Collected
+
+
+ {!hasActivity ? (
+
+ No billed or collected amounts in this period.
+
+ ) : null}
+
+
+ Monthly billed and collected amounts in {data.currency}
+
+ Comparison for the last {data.monthly.length} months. Exact amounts are available in the monthly figures
+ table below.
+
+ {[0, 0.5, 1].map((fraction) => {
+ const amount = maximum.minus(scale.times(fraction));
+ const y = chartTop + chartHeight * fraction;
+ return (
+
+
+
+ {hasActivity ? compactAmount(amount) : fraction === 1 ? "0" : ""}
+
+
+ );
+ })}
+
+ {data.monthly.map((month, index) => {
+ const center = chartLeft + step * (index + 0.5);
+ return (
+
+ {(["billed", "collected"] as const).map((key, seriesIndex) => {
+ const amount = new Decimal(month[key]);
+ const valueY = hasActivity ? yPosition(amount) : zeroY;
+ return (
+
+
+ {monthLabel(month.month)} — {key === "billed" ? "Billed" : "Collected"}:{" "}
+ {formatDashboardAmount(month[key], data.currency)}
+
+
+ );
+ })}
+
+ {monthLabel(month.month, true)}
+
+
+ );
+ })}
+
+
+
+
+ View monthly figures
+
+
+
+ Monthly figures in {data.currency}
+
+
+
+ Month
+
+
+ Billed
+
+
+ Collected
+
+
+
+
+ {data.monthly.map((month) => (
+
+
+ {monthLabel(month.month)}
+
+
+ {formatDashboardAmount(month.billed, data.currency)}
+
+
+ {formatDashboardAmount(month.collected, data.currency)}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export { MonthlyTrend };
diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 00000000..b8600aee
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx
@@ -0,0 +1,13 @@
+import { generateWebsiteMetadata } from "@/constants/meta-data";
+import { FinancialDashboard } from "./dashboard.client";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = generateWebsiteMetadata({
+ title: "Financial Dashboard | Invoicely",
+});
+
+function DashboardPage() {
+ return ;
+}
+
+export default DashboardPage;
diff --git a/apps/web/src/assets/icons/dashboard.tsx b/apps/web/src/assets/icons/dashboard.tsx
new file mode 100644
index 00000000..6005bafd
--- /dev/null
+++ b/apps/web/src/assets/icons/dashboard.tsx
@@ -0,0 +1,31 @@
+import type { IconProps } from "@/types";
+
+function DashboardIcon({ fill = "currentColor", secondaryfill = fill, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { DashboardIcon };
diff --git a/apps/web/src/assets/icons/index.ts b/apps/web/src/assets/icons/index.ts
index 2b3dd7b1..9aa70145 100644
--- a/apps/web/src/assets/icons/index.ts
+++ b/apps/web/src/assets/icons/index.ts
@@ -1,4 +1,5 @@
// Follow the naming convention: NameIcon
+export { DashboardIcon } from "./dashboard";
export { default as SidebarMenuIcon } from "./sidebar-menu";
export { default as SquareWandSparkleIcon } from "./square-wand-sparkle";
export { default as ReceiptIcon } from "./recipt";
diff --git a/apps/web/src/constants/sidebar.tsx b/apps/web/src/constants/sidebar.tsx
index 98379bdb..d0a42f52 100644
--- a/apps/web/src/constants/sidebar.tsx
+++ b/apps/web/src/constants/sidebar.tsx
@@ -1,9 +1,14 @@
-import { FolderFeatherIcon, ReceiptIcon, VersionsIcon } from "@/assets/icons";
+import { DashboardIcon, FolderFeatherIcon, ReceiptIcon, VersionsIcon } from "@/assets/icons";
import type { ISidebar } from "@/types";
import { LINKS } from "./links";
export const SIDEBAR_ITEMS: ISidebar = {
Navigation: [
+ {
+ name: "Dashboard",
+ url: LINKS.DASHBOARD,
+ icon: ,
+ },
{
name: "Invoices",
url: LINKS.INVOICES,
diff --git a/apps/web/src/lib/dashboard/financial-dashboard.ts b/apps/web/src/lib/dashboard/financial-dashboard.ts
new file mode 100644
index 00000000..75a80cb7
--- /dev/null
+++ b/apps/web/src/lib/dashboard/financial-dashboard.ts
@@ -0,0 +1,253 @@
+import Decimal from "decimal.js";
+
+// Keep calculations independent of Decimal configuration used elsewhere in the app.
+const Money = Decimal.clone({ precision: 64, rounding: Decimal.ROUND_HALF_UP });
+
+type DashboardDate = Date | string;
+
+/** The dashboard query and locally saved invoices both satisfy this minimal shape. */
+export interface DashboardInvoice {
+ id: string;
+ type: "local" | "server";
+ status: "pending" | "success" | "error" | "expired" | "refunded";
+ paidAt?: DashboardDate | null;
+ invoiceFields: {
+ clientDetails: { name: string; address: string };
+ invoiceDetails: {
+ currency: string;
+ date: DashboardDate;
+ dueDate?: DashboardDate | null;
+ billingDetails: { type: "fixed" | "percentage"; value: Decimal.Value }[];
+ };
+ items: { quantity: Decimal.Value; unitPrice: Decimal.Value }[];
+ };
+}
+
+export interface DashboardMonth {
+ month: string;
+ billed: string;
+ collected: string;
+}
+
+export interface DashboardClient {
+ id: string;
+ name: string;
+ address: string;
+ invoiceCount: number;
+ billed: string;
+ collected: string;
+ outstanding: string;
+}
+
+export interface DashboardCurrency {
+ currency: string;
+ invoiceCount: number;
+ collected: string;
+ outstanding: string;
+ overdue: string;
+ collectedCount: number;
+ outstandingCount: number;
+ overdueCount: number;
+ undatedCollectedCount: number;
+ monthly: DashboardMonth[];
+ topClients: DashboardClient[];
+}
+
+export interface FinancialDashboard {
+ invoiceCount: number;
+ currencies: DashboardCurrency[];
+}
+
+/** Prefer the server record when a locally cached copy has the same invoice ID. */
+export function mergeDashboardInvoices(
+ localInvoices: readonly DashboardInvoice[],
+ serverInvoices: readonly DashboardInvoice[],
+): DashboardInvoice[] {
+ return Array.from(new Map([...localInvoices, ...serverInvoices].map((invoice) => [invoice.id, invoice])).values());
+}
+
+/** Same formula as the PDF: percentages apply to the subtotal, then round the invoice to two decimals. */
+export function getDashboardInvoiceTotal(invoice: DashboardInvoice): string {
+ const subtotal = invoice.invoiceFields.items.reduce(
+ (sum, item) => sum.plus(new Money(item.quantity).times(item.unitPrice)),
+ new Money(0),
+ );
+
+ return invoice.invoiceFields.invoiceDetails.billingDetails
+ .reduce(
+ (total, rate) => total.plus(rate.type === "percentage" ? subtotal.times(rate.value).dividedBy(100) : rate.value),
+ subtotal,
+ )
+ .toDecimalPlaces(2)
+ .toFixed();
+}
+
+function asDate(value: DashboardDate | null | undefined): Date | null {
+ if (!value) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function monthKey(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
+}
+
+function dayStart(date: Date): number {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+}
+
+function add(left: string, right: string): string {
+ return new Money(left).plus(right).toFixed();
+}
+
+function clientKey(name: string, address: string): string {
+ const normalize = (value: string) => value.trim().replace(/\s+/g, " ").toLowerCase();
+ return JSON.stringify([normalize(name), normalize(address)]);
+}
+
+/**
+ * Summaries and client rankings cover all time; only monthly trends use the window.
+ * Pending, failed, and expired invoices remain outstanding. Overdue is a subset of
+ * outstanding, based on a past calendar due date or explicit expired status.
+ * Refunded invoices do not contribute to financial totals. Successful invoices
+ * without a payment date count toward collected, but never an invented payment month.
+ */
+export function buildFinancialDashboard(
+ invoices: readonly DashboardInvoice[],
+ { now = new Date(), months = 6, topClientLimit = 5 }: { now?: Date; months?: 6 | 12; topClientLimit?: number } = {},
+): FinancialDashboard {
+ const monthKeys = Array.from({ length: months }, (_, index) =>
+ monthKey(new Date(now.getFullYear(), now.getMonth() - months + index + 1, 1)),
+ );
+ const currencies = new Map }>();
+ const today = dayStart(now);
+
+ for (const invoice of invoices) {
+ const details = invoice.invoiceFields.invoiceDetails;
+ const currency = details.currency.trim().toUpperCase();
+ let group = currencies.get(currency);
+
+ if (!group) {
+ group = {
+ summary: {
+ currency,
+ invoiceCount: 0,
+ collected: "0",
+ outstanding: "0",
+ overdue: "0",
+ collectedCount: 0,
+ outstandingCount: 0,
+ overdueCount: 0,
+ undatedCollectedCount: 0,
+ monthly: monthKeys.map((month) => ({ month, billed: "0", collected: "0" })),
+ topClients: [],
+ },
+ clients: new Map(),
+ };
+ currencies.set(currency, group);
+ }
+
+ const { summary, clients } = group;
+ summary.invoiceCount += 1;
+ if (invoice.status === "refunded") continue;
+
+ const amount = getDashboardInvoiceTotal(invoice);
+ const paid = invoice.status === "success";
+ const dueDate = asDate(details.dueDate);
+ const overdue = !paid && (invoice.status === "expired" || (dueDate !== null && dayStart(dueDate) < today));
+
+ if (paid) {
+ summary.collected = add(summary.collected, amount);
+ summary.collectedCount += 1;
+ const paidAt = asDate(invoice.paidAt);
+ if (paidAt) {
+ const month = summary.monthly.find((entry) => entry.month === monthKey(paidAt));
+ if (month) month.collected = add(month.collected, amount);
+ } else {
+ summary.undatedCollectedCount += 1;
+ }
+ } else {
+ summary.outstanding = add(summary.outstanding, amount);
+ summary.outstandingCount += 1;
+ }
+
+ if (overdue) {
+ summary.overdue = add(summary.overdue, amount);
+ summary.overdueCount += 1;
+ }
+
+ const invoiceDate = asDate(details.date);
+ const month = invoiceDate ? summary.monthly.find((entry) => entry.month === monthKey(invoiceDate)) : undefined;
+ if (month) month.billed = add(month.billed, amount);
+
+ const { name, address } = invoice.invoiceFields.clientDetails;
+ const id = clientKey(name, address);
+ const client = clients.get(id) ?? {
+ id,
+ name: name.trim(),
+ address: address.trim(),
+ invoiceCount: 0,
+ billed: "0",
+ collected: "0",
+ outstanding: "0",
+ };
+ client.invoiceCount += 1;
+ client.billed = add(client.billed, amount);
+ if (paid) client.collected = add(client.collected, amount);
+ else client.outstanding = add(client.outstanding, amount);
+ clients.set(id, client);
+ }
+
+ return {
+ invoiceCount: invoices.length,
+ currencies: Array.from(currencies.values())
+ .map(({ summary, clients }) => ({
+ ...summary,
+ topClients: Array.from(clients.values())
+ .sort(
+ (left, right) =>
+ new Money(right.billed).comparedTo(left.billed) ||
+ left.name.localeCompare(right.name) ||
+ left.address.localeCompare(right.address),
+ )
+ .slice(0, Math.max(0, topClientLimit)),
+ }))
+ .sort((left, right) => left.currency.localeCompare(right.currency)),
+ };
+}
+
+/** Format exact decimal strings without converting money to a JavaScript number. */
+export function formatDashboardAmount(amount: string, currency: string, locale = "en-US"): string {
+ let formatter: Intl.NumberFormat;
+ try {
+ // Match the two-decimal invoice amounts displayed in the app and generated PDFs.
+ formatter = new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency,
+ currencyDisplay: "code",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ } catch {
+ // Existing invoices may contain a custom currency label instead of an ISO code.
+ return `${currency} ${new Money(amount).toFixed(2)}`;
+ }
+
+ const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2;
+ const rounded = new Money(amount).toDecimalPlaces(decimals);
+ const [integer, fraction] = rounded.abs().toFixed(decimals).split(".");
+ const wholeParts = formatter.formatToParts(BigInt(integer!));
+ const groupedInteger = wholeParts
+ .filter((part) => part.type === "integer" || part.type === "group")
+ .map((part) => part.value)
+ .join("");
+ const template = formatter.formatToParts(BigInt(rounded.isNegative() && !rounded.isZero() ? -1 : 1));
+
+ return template
+ .map((part) => {
+ if (part.type === "integer") return groupedInteger;
+ if (part.type === "fraction") return fraction;
+ return part.value;
+ })
+ .join("");
+}
diff --git a/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts b/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts
new file mode 100644
index 00000000..3bc81eac
--- /dev/null
+++ b/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts
@@ -0,0 +1,36 @@
+import { db, schema } from "@invoicely/db";
+import { eq } from "drizzle-orm";
+
+/** Fetch only the financial fields needed to summarize the current user's invoices. */
+export const getDashboardInvoicesQuery = async (userId: string) => {
+ return db.query.invoices.findMany({
+ where: eq(schema.invoices.userId, userId),
+ columns: {
+ id: true,
+ type: true,
+ status: true,
+ paidAt: true,
+ },
+ with: {
+ invoiceFields: {
+ columns: {},
+ with: {
+ clientDetails: {
+ columns: { name: true, address: true },
+ },
+ invoiceDetails: {
+ columns: { currency: true, date: true, dueDate: true },
+ with: {
+ billingDetails: {
+ columns: { type: true, value: true },
+ },
+ },
+ },
+ items: {
+ columns: { quantity: true, unitPrice: true },
+ },
+ },
+ },
+ },
+ });
+};
diff --git a/apps/web/src/trpc/services/invoice/getDashboard.ts b/apps/web/src/trpc/services/invoice/getDashboard.ts
new file mode 100644
index 00000000..a3547686
--- /dev/null
+++ b/apps/web/src/trpc/services/invoice/getDashboard.ts
@@ -0,0 +1,43 @@
+import { getDashboardInvoicesQuery } from "@/lib/db-queries/invoice/getDashboardInvoices";
+import { authorizedProcedure } from "@/trpc/procedures/authorizedProcedure";
+import { parseCatchError } from "@/lib/neverthrow/parseCatchError";
+import { InternalServerError } from "@/lib/effect/error/trpc";
+import { TRPCError } from "@trpc/server";
+import { Effect } from "effect";
+
+export const getDashboard = authorizedProcedure.query(async ({ ctx }) => {
+ const dashboardEffect = Effect.gen(function* () {
+ const invoices = yield* Effect.tryPromise({
+ try: () => getDashboardInvoicesQuery(ctx.auth.user.id),
+ catch: (error) => new InternalServerError({ message: parseCatchError(error) }),
+ });
+
+ // Decimal strings retain database precision across the API boundary.
+ return invoices.map((invoice) => ({
+ ...invoice,
+ invoiceFields: {
+ ...invoice.invoiceFields,
+ items: invoice.invoiceFields.items.map((item) => ({
+ ...item,
+ unitPrice: item.unitPrice.toString(),
+ })),
+ invoiceDetails: {
+ ...invoice.invoiceFields.invoiceDetails,
+ billingDetails: invoice.invoiceFields.invoiceDetails.billingDetails.map((detail) => ({
+ ...detail,
+ value: detail.value.toString(),
+ })),
+ },
+ },
+ }));
+ });
+
+ return Effect.runPromise(
+ dashboardEffect.pipe(
+ Effect.catchTags({
+ InternalServerError: (error) =>
+ Effect.fail(new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: error.message })),
+ }),
+ ),
+ );
+});
diff --git a/apps/web/src/trpc/services/invoice/index.ts b/apps/web/src/trpc/services/invoice/index.ts
index 66c0ba1b..5ae766da 100644
--- a/apps/web/src/trpc/services/invoice/index.ts
+++ b/apps/web/src/trpc/services/invoice/index.ts
@@ -3,11 +3,13 @@ import { insertInvoice } from "./insertInvoice";
import { deleteInvoice } from "./deleteInvoice";
import { createTRPCRouter } from "@/trpc/init";
import { listInvoices } from "./listInvoices";
+import { getDashboard } from "./getDashboard";
import { migrateToDb } from "./migrateToDb";
import { editInvoice } from "./editInvoice";
import { getInvoice } from "./getInvoice";
export const invoiceRouter = createTRPCRouter({
+ dashboard: getDashboard,
list: listInvoices,
insert: insertInvoice,
updateStatus: updateInvoiceStatus,