Expense Management
The expense management feature enables users to track spending, categorize expenses, and analyze spending patterns across eight predefined categories.Expense Model
Database Schema
lib/db/models.ts
export type ExpenseCategory =
| "food"
| "transport"
| "shopping"
| "housing"
| "utilities"
| "entertainment"
| "health"
| "other";
export interface ExpenseDocument extends Document {
userId: string;
name: string;
amount: number;
category: ExpenseCategory;
date: Date;
note?: string;
createdAt: Date;
updatedAt: Date;
}
const expenseSchema = new Schema<ExpenseDocument>({
userId: { type: String, required: true, index: true },
name: { type: String, required: true, trim: true },
amount: { type: Number, required: true, min: 0 },
category: {
type: String,
required: true,
enum: [
"food",
"transport",
"shopping",
"housing",
"utilities",
"entertainment",
"health",
"other",
],
default: "other",
},
date: { type: Date, required: true },
note: { type: String, trim: true },
}, { timestamps: true });
// Compound indexes for efficient queries
expenseSchema.index({ userId: 1, date: -1 });
expenseSchema.index({ userId: 1, category: 1 });
export const ExpenseModel: Model<ExpenseDocument> =
mongoose.models.Expense ||
mongoose.model<ExpenseDocument>("Expense", expenseSchema);
Expense Categories
Each category has custom styling and iconography:app/expenses/page.tsx
import {
Utensils,
Car,
ShoppingBag,
Home,
Zap,
Gamepad2,
Heart,
MoreHorizontal,
} from "lucide-react";
const categoryConfig: Record<ExpenseCategory, {
label: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
bg: string;
}> = {
food: {
label: "Food & Dining",
icon: Utensils,
color: "text-orange-500",
bg: "bg-orange-500/10",
},
transport: {
label: "Transport",
icon: Car,
color: "text-blue-500",
bg: "bg-blue-500/10",
},
shopping: {
label: "Shopping",
icon: ShoppingBag,
color: "text-pink-500",
bg: "bg-pink-500/10",
},
housing: {
label: "Housing",
icon: Home,
color: "text-purple-500",
bg: "bg-purple-500/10",
},
utilities: {
label: "Utilities",
icon: Zap,
color: "text-yellow-500",
bg: "bg-yellow-500/10",
},
entertainment: {
label: "Entertainment",
icon: Gamepad2,
color: "text-indigo-500",
bg: "bg-indigo-500/10",
},
health: {
label: "Health",
icon: Heart,
color: "text-red-500",
bg: "bg-red-500/10",
},
other: {
label: "Other",
icon: MoreHorizontal,
color: "text-gray-500",
bg: "bg-gray-500/10",
},
};
Food & Dining
Restaurants, groceries, coffee shops, meal delivery services
Transportation
Gas, public transit, parking fees, car maintenance, rideshare
Shopping
Clothing, electronics, home goods, personal items, gifts
Housing
Rent, mortgage payments, property taxes, home insurance
Utilities
Electricity, water, gas, internet, phone bills
Entertainment
Movies, games, hobbies, events, concerts, streaming
Health
Medical expenses, dental, pharmacy, gym membership
Other
Miscellaneous expenses not fitting other categories
API Endpoints
Get All Expenses
app/api/expenses/route.ts
import { NextRequest } from "next/server";
import { connectDB } from "@/lib/db/connection";
import { ExpenseModel } from "@/lib/db/models";
import {
authenticateRequest,
getPaginationParams,
paginatedResponse,
} from "@/lib/api/utils";
export async function GET(request: NextRequest) {
try {
const { auth, error: authError } = await authenticateRequest(request);
if (authError) return authError;
await connectDB();
const pagination = getPaginationParams(request);
const url = new URL(request.url);
// Build query filters
const query: Record<string, unknown> = { userId: auth.userId };
// Optional date range filter
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
if (startDate || endDate) {
query.date = {};
if (startDate) query.date.$gte = new Date(startDate);
if (endDate) query.date.$lte = new Date(endDate);
}
// Optional category filter
const category = url.searchParams.get("category");
if (category) {
query.category = category;
}
// Get total count and items
const [total, expenses] = await Promise.all([
ExpenseModel.countDocuments(query),
ExpenseModel.find(query)
.sort({ date: -1 })
.skip(pagination.skip)
.limit(pagination.limit)
.lean(),
]);
// Transform to client format
const items = expenses.map((expense) => ({
id: expense._id.toString(),
name: expense.name,
amount: expense.amount,
category: expense.category,
date: expense.date.toISOString(),
note: expense.note,
createdAt: expense.createdAt.toISOString(),
updatedAt: expense.updatedAt.toISOString(),
}));
return paginatedResponse(items, total, pagination);
} catch (error) {
console.error("Expense list error:", error);
return errorResponse("INTERNAL_ERROR", "Failed to fetch expenses", 500);
}
}
page- Page number (default: 1)limit- Items per page (default: 50)startDate- Filter from date (ISO format)endDate- Filter to date (ISO format)category- Filter by expense category
Create Expense
app/api/expenses/route.ts
import { createExpenseSchema } from "@/lib/validations";
import { parseBody, successResponse } from "@/lib/api/utils";
export async function POST(request: NextRequest) {
try {
const { auth, error: authError } = await authenticateRequest(request);
if (authError) return authError;
const { data, error: parseError } = await parseBody(
request,
createExpenseSchema
);
if (parseError) return parseError;
await connectDB();
const expense = await ExpenseModel.create({
userId: auth.userId,
name: data.name,
amount: data.amount,
category: data.category,
date: new Date(data.date),
note: data.note,
});
return successResponse(
{
id: expense._id.toString(),
name: expense.name,
amount: expense.amount,
category: expense.category,
date: expense.date.toISOString(),
note: expense.note,
createdAt: expense.createdAt.toISOString(),
updatedAt: expense.updatedAt.toISOString(),
},
201
);
} catch (error) {
console.error("Expense create error:", error);
return errorResponse("INTERNAL_ERROR", "Failed to create expense", 500);
}
}
{
"name": "Grocery Shopping",
"amount": 127.50,
"category": "food",
"date": "2024-03-15",
"note": "Weekly groceries from Whole Foods"
}
Update & Delete
Update and delete operations follow the same pattern as income endpoints:app/api/expenses/[id]/route.ts
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
// Similar to income PATCH
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
// Similar to income DELETE
}
Validation
lib/validations.ts
import { z } from "zod";
export const expenseCategorySchema = z.enum([
"food",
"transport",
"shopping",
"housing",
"utilities",
"entertainment",
"health",
"other",
]);
export const createExpenseSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
amount: z.number().positive("Amount must be positive"),
category: expenseCategorySchema,
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Invalid date format",
}),
note: z.string().max(500).optional(),
});
export const updateExpenseSchema = createExpenseSchema.partial();
export type CreateExpenseInput = z.infer<typeof createExpenseSchema>;
export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>;
Expense Page UI
The expense page displays spending statistics and categorized expenses:app/expenses/page.tsx
export default function ExpensesPage() {
const { data, isLoading } = useDashboardData();
const expenses = data?.expenses ?? [];
const [categoryOpen, setCategoryOpen] = useState(true);
// Calculate totals
const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0);
// Group by category
const expensesByCategory = expenses.reduce(
(acc, expense) => {
acc[expense.category] = (acc[expense.category] || 0) + expense.amount;
return acc;
},
{} as Record<ExpenseCategory, number>
);
// This month's expenses
const thisMonth = new Date().toISOString().slice(0, 7);
const thisMonthExpenses = expenses.filter(e => e.date.startsWith(thisMonth));
const thisMonthTotal = thisMonthExpenses.reduce((sum, e) => sum + e.amount, 0);
return (
<DashboardWrapper className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Expenses</h1>
<p className="text-muted-foreground text-lg">
Track where your money goes
</p>
</div>
<Button asChild className="rounded-2xl">
<Link href="/expenses/new">
<Plus className="mr-2 h-4 w-4" />
Add Expense
</Link>
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StatCard
label="Total Expenses"
value={totalExpenses}
icon={TrendingDown}
color="text-red-500"
/>
<StatCard
label="This Month"
value={thisMonthTotal}
icon={TrendingDown}
color="text-red-500"
/>
</div>
{/* Category Breakdown */}
{expenses.length > 0 && (
<CategoryBreakdown
expensesByCategory={expensesByCategory}
isOpen={categoryOpen}
onToggle={setCategoryOpen}
/>
)}
{/* Expense List */}
<ExpenseList expenses={expenses} />
</DashboardWrapper>
);
}
Category Breakdown Component
Visual breakdown of spending by category:function CategoryBreakdown({
expensesByCategory,
isOpen,
onToggle,
}: {
expensesByCategory: Record<ExpenseCategory, number>;
isOpen: boolean;
onToggle: (open: boolean) => void;
}) {
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-3 w-full">
<ShoppingBag className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">By Category</h2>
<div className="flex-1 h-px bg-border" />
<span className="text-sm text-muted-foreground">
{Object.keys(expensesByCategory).length} categories
</span>
<ChevronDown
className={cn(
"h-5 w-5 transition-transform",
isOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 pt-2">
{(Object.keys(categoryConfig) as ExpenseCategory[]).map((cat) => {
const config = categoryConfig[cat];
const amount = expensesByCategory[cat] || 0;
const Icon = config.icon;
return (
<div
key={cat}
className="p-4 rounded-2xl border bg-card text-center"
>
<div className={`h-10 w-10 rounded-xl ${config.bg} flex items-center justify-center mx-auto mb-2`}>
<Icon className={`h-5 w-5 ${config.color}`} />
</div>
<p className="text-xs text-muted-foreground mb-1">
{config.label}
</p>
<p className="font-semibold">{formatCurrency(amount)}</p>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
Expense Card Component
function ExpenseCard({ expense }: { expense: Expense }) {
const { deleteExpense } = useFinanceStore();
const { addToast } = useToast();
const queryClient = useQueryClient();
const config = categoryConfig[expense.category];
const Icon = config.icon;
const handleDelete = async () => {
try {
await deleteExpense(expense.id);
await queryClient.invalidateQueries({ queryKey: queryKeys.dashboard });
addToast({
type: "success",
title: "Expense deleted",
message: `"${expense.name}" has been deleted`,
});
} catch (error) {
addToast({
type: "error",
title: "Failed to delete",
message: "Please try again.",
});
}
};
return (
<div className="flex items-center justify-between p-5 rounded-3xl border bg-card">
<div className="flex items-center gap-4">
<div className={`h-12 w-12 rounded-2xl ${config.bg} flex items-center justify-center`}>
<Icon className={`h-6 w-6 ${config.color}`} />
</div>
<div>
<h3 className="font-semibold">{expense.name}</h3>
<p className="text-sm text-muted-foreground">
{config.label} • {formatDate(expense.date)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<p className="text-xl font-bold text-red-600">
-{formatCurrency(expense.amount)}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2.5 hover:bg-muted rounded-2xl">
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/expenses/${expense.id}/edit`}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
Spending Analysis
Calculate spending patterns on the dashboard:app/(dashboard)/dashboard/page.tsx
const spendingByCategory = useMemo(() => {
const categoryTotals: Record<string, number> = {};
expenses.forEach((e) => {
categoryTotals[e.category] = (categoryTotals[e.category] || 0) + e.amount;
});
const totalSpending = Object.values(categoryTotals).reduce(
(sum, v) => sum + v,
0
);
return Object.entries(categoryTotals)
.map(([category, amount]) => ({
category,
amount,
percentage: totalSpending > 0 ? (amount / totalSpending) * 100 : 0,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5); // Top 5 categories
}, [expenses]);
The spending analysis automatically updates as users add or remove expenses, providing real-time insights into their spending habits.
Next Steps
Subscriptions
Manage recurring subscription payments
Features
Explore all CashGap features