diff --git a/Dockerfile b/Dockerfile
index 485cffc..e1c5d45 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ COPY prisma ./prisma
RUN npm ci
COPY . .
-RUN npm run prisma:generate && npm run build
+RUN npm run prisma:generate && rm -rf .next/node_modules && npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..8c80ff2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ serverExternalPackages: ["@prisma/client"],
};
export default nextConfig;
diff --git a/prisma/migrations/20260323234807_add_recurring_expenses/migration.sql b/prisma/migrations/20260323234807_add_recurring_expenses/migration.sql
new file mode 100644
index 0000000..d240c0c
--- /dev/null
+++ b/prisma/migrations/20260323234807_add_recurring_expenses/migration.sql
@@ -0,0 +1,11 @@
+-- CreateTable
+CREATE TABLE "RecurringExpense" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "amountCents" INTEGER NOT NULL,
+ "category" TEXT NOT NULL,
+ "dayOfMonth" INTEGER NOT NULL,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3be8dd3..9226c14 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -50,6 +50,17 @@ model MerchantCorrection {
updatedAt DateTime @updatedAt
}
+model RecurringExpense {
+ id String @id @default(cuid())
+ title String
+ amountCents Int
+ category Category
+ dayOfMonth Int // 1–28, capped so it is valid in every month including February
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
model MonthlyInsight {
id String @id @default(cuid())
month String @unique
diff --git a/src/app/add-expense/page.tsx b/src/app/add-expense/page.tsx
index 84af404..a8d5f7a 100644
--- a/src/app/add-expense/page.tsx
+++ b/src/app/add-expense/page.tsx
@@ -1,4 +1,5 @@
import { ExpenseWorkspace } from "@/components/expense-workspace";
+import { RecurringExpenseManager } from "@/components/recurring-expense-manager";
import { CATEGORY_OPTIONS } from "@/lib/categories";
export const metadata = {
@@ -16,6 +17,8 @@ export default function AddExpensePage() {
+ ({ ...option }))} />
+
({ ...option }))} />
);
diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts
index 1ada9ec..89df00e 100644
--- a/src/app/dashboard/route.ts
+++ b/src/app/dashboard/route.ts
@@ -21,6 +21,7 @@ export async function GET(request: Request) {
return NextResponse.json(dashboard);
} catch (error) {
console.error("[dashboard] snapshot error:", error);
- return NextResponse.json({ error: "Could not load the dashboard." }, { status: 500 });
+ const msg = error instanceof Error ? error.message : String(error);
+ return NextResponse.json({ error: "Could not load the dashboard.", detail: msg }, { status: 500 });
}
}
diff --git a/src/app/expenses/route.ts b/src/app/expenses/route.ts
index 75166b5..e437a3a 100644
--- a/src/app/expenses/route.ts
+++ b/src/app/expenses/route.ts
@@ -1,11 +1,32 @@
import { NextResponse } from "next/server";
+import { isDateInMonth, isValidMonthKey } from "@/lib/date";
import { createExpense, listExpenses } from "@/lib/expenses";
+import { getProjectedRecurringExpenses, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
import { expenseInputSchema } from "@/lib/validation";
-export async function GET() {
- const expenses = await listExpenses();
- return NextResponse.json({ expenses });
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const rawMonth = url.searchParams.get("month");
+
+ try {
+ const expenses = await listExpenses();
+
+ if (rawMonth && isValidMonthKey(rawMonth)) {
+ const definitions = await listActiveRecurringExpenses();
+ const projected = getProjectedRecurringExpenses(definitions, rawMonth);
+ const monthReal = expenses
+ .filter((e) => isDateInMonth(e.date, rawMonth))
+ .map((e) => ({ ...e, isRecurring: false as const }));
+ const all = [...monthReal, ...projected].sort((a, b) => b.date.localeCompare(a.date));
+ return NextResponse.json({ expenses: all });
+ }
+
+ return NextResponse.json({ expenses });
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ return NextResponse.json({ error: "Could not load expenses.", detail: msg }, { status: 500 });
+ }
}
export async function POST(request: Request) {
diff --git a/src/app/recurring-expenses/[id]/route.ts b/src/app/recurring-expenses/[id]/route.ts
new file mode 100644
index 0000000..7492ae7
--- /dev/null
+++ b/src/app/recurring-expenses/[id]/route.ts
@@ -0,0 +1,51 @@
+import { Prisma } from "@prisma/client";
+import { NextResponse } from "next/server";
+
+import { deactivateRecurringExpense, updateRecurringExpense } from "@/lib/recurring-expenses";
+import { recurringExpenseInputSchema } from "@/lib/validation";
+
+type RouteContext = {
+ params: Promise<{ id: string }>;
+};
+
+export async function PATCH(request: Request, context: RouteContext) {
+ const { id } = await context.params;
+ const payload = await request.json();
+ const parsed = recurringExpenseInputSchema.safeParse(payload);
+
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
+ { status: 400 },
+ );
+ }
+
+ try {
+ const recurringExpense = await updateRecurringExpense(id, {
+ title: parsed.data.title,
+ amountCents: parsed.data.amount,
+ dayOfMonth: parsed.data.dayOfMonth,
+ category: parsed.data.category,
+ });
+ return NextResponse.json({ recurringExpense });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
+ return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
+ }
+ throw error;
+ }
+}
+
+export async function DELETE(_: Request, context: RouteContext) {
+ const { id } = await context.params;
+
+ try {
+ await deactivateRecurringExpense(id);
+ return new NextResponse(null, { status: 204 });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
+ return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
+ }
+ throw error;
+ }
+}
diff --git a/src/app/recurring-expenses/route.ts b/src/app/recurring-expenses/route.ts
new file mode 100644
index 0000000..b3828f0
--- /dev/null
+++ b/src/app/recurring-expenses/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+
+import { createRecurringExpense, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
+import { recurringExpenseInputSchema } from "@/lib/validation";
+
+export async function GET() {
+ const recurringExpenses = await listActiveRecurringExpenses();
+ return NextResponse.json({ recurringExpenses });
+}
+
+export async function POST(request: Request) {
+ const payload = await request.json();
+ const parsed = recurringExpenseInputSchema.safeParse(payload);
+
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
+ { status: 400 },
+ );
+ }
+
+ const recurringExpense = await createRecurringExpense({
+ title: parsed.data.title,
+ amountCents: parsed.data.amount,
+ dayOfMonth: parsed.data.dayOfMonth,
+ category: parsed.data.category,
+ });
+
+ return NextResponse.json({ recurringExpense }, { status: 201 });
+}
diff --git a/src/components/expense-workspace.tsx b/src/components/expense-workspace.tsx
index b968b63..10e2057 100644
--- a/src/components/expense-workspace.tsx
+++ b/src/components/expense-workspace.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
+import { getCurrentMonthKey } from "@/lib/date";
import { formatCurrencyFromCents } from "@/lib/money";
type SuggestionResponse = {
@@ -19,6 +20,7 @@ type ExpenseRecord = {
amountCents: number;
date: string;
category: CategoryValue;
+ isRecurring?: boolean;
};
type CategoryOption = {
@@ -53,9 +55,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
useEffect(() => {
async function loadExpenses() {
- const response = await fetch("/expenses", { cache: "no-store" });
- const payload = (await response.json()) as { expenses?: ExpenseRecord[] };
- setExpenses(payload.expenses ?? []);
+ const month = getCurrentMonthKey();
+ const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
+ const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
+ setExpenses(payload?.expenses ?? []);
}
void loadExpenses();
@@ -335,27 +338,38 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
>
-
{expense.title}
+
+
{expense.title}
+ {expense.isRecurring && (
+
+ Recurring
+
+ )}
+
{expense.date} · {getCategoryLabel(expense.category)}
{formatCurrencyFromCents(expense.amountCents)}
-
-
+ {!expense.isRecurring && (
+ <>
+
+
+ >
+ )}
))
diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx
index 8d01128..e619d39 100644
--- a/src/components/home-dashboard.tsx
+++ b/src/components/home-dashboard.tsx
@@ -26,7 +26,7 @@ type DashboardSnapshot = {
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
};
categoryBreakdown: Array<{ category: string; amountCents: number }>;
- recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
+ recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string; isRecurring?: true }>;
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
};
@@ -445,7 +445,14 @@ export function HomeDashboard() {
snapshot.recentExpenses.map((expense) => (
-
{expense.title}
+
+
{expense.title}
+ {expense.isRecurring && (
+
+ Recurring
+
+ )}
+
{expense.date} · {getCategoryLabel(expense.category as never)}
diff --git a/src/components/recurring-expense-manager.tsx b/src/components/recurring-expense-manager.tsx
new file mode 100644
index 0000000..5e8d943
--- /dev/null
+++ b/src/components/recurring-expense-manager.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import { useEffect, useState, type FormEvent } from "react";
+
+import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
+import { formatCurrencyFromCents } from "@/lib/money";
+
+type RecurringDefinition = {
+ id: string;
+ title: string;
+ amountCents: number;
+ category: string;
+ dayOfMonth: number;
+};
+
+type CategoryOption = {
+ value: string;
+ label: string;
+};
+
+type Props = {
+ categoryOptions: CategoryOption[];
+};
+
+const emptyForm = { title: "", amount: "", dayOfMonth: "1", category: "MISC" as CategoryValue };
+
+export function RecurringExpenseManager({ categoryOptions }: Props) {
+ const [definitions, setDefinitions] = useState
([]);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formState, setFormState] = useState(emptyForm);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ async function load() {
+ const res = await fetch("/recurring-expenses", { cache: "no-store" });
+ const payload = (await res.json()) as { recurringExpenses?: RecurringDefinition[] };
+ setDefinitions(payload.recurringExpenses ?? []);
+ }
+ void load();
+ }, []);
+
+ function openAdd() {
+ setEditingId(null);
+ setFormState(emptyForm);
+ setError(null);
+ setShowAddForm(true);
+ }
+
+ function openEdit(def: RecurringDefinition) {
+ setShowAddForm(false);
+ setEditingId(def.id);
+ setFormState({
+ title: def.title,
+ amount: (def.amountCents / 100).toFixed(2),
+ dayOfMonth: String(def.dayOfMonth),
+ category: def.category as CategoryValue,
+ });
+ setError(null);
+ }
+
+ function cancelForm() {
+ setShowAddForm(false);
+ setEditingId(null);
+ setFormState(emptyForm);
+ setError(null);
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setError(null);
+
+ const isEditing = editingId !== null;
+ const url = isEditing ? `/recurring-expenses/${editingId}` : "/recurring-expenses";
+ const response = await fetch(url, {
+ method: isEditing ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: formState.title,
+ amount: formState.amount,
+ dayOfMonth: parseInt(formState.dayOfMonth, 10),
+ category: formState.category,
+ }),
+ });
+
+ setBusy(false);
+
+ if (!response.ok) {
+ const payload = (await response.json().catch(() => null)) as { error?: string } | null;
+ setError(payload?.error ?? "Could not save.");
+ return;
+ }
+
+ const payload = (await response.json()) as { recurringExpense: RecurringDefinition };
+
+ if (isEditing) {
+ setDefinitions((current) =>
+ current.map((d) => (d.id === editingId ? payload.recurringExpense : d)),
+ );
+ } else {
+ setDefinitions((current) => [...current, payload.recurringExpense]);
+ }
+
+ cancelForm();
+ }
+
+ async function handleDelete(id: string) {
+ setBusy(true);
+ const response = await fetch(`/recurring-expenses/${id}`, { method: "DELETE" });
+ setBusy(false);
+
+ if (!response.ok) {
+ setError("Could not delete.");
+ return;
+ }
+
+ setDefinitions((current) => current.filter((d) => d.id !== id));
+ }
+
+ return (
+
+
+
+
Fixed monthly costs
+
Recurring expenses
+
These appear automatically in every month without manual entry.
+
+ {!showAddForm && editingId === null && (
+
+ )}
+
+
+ {(showAddForm || editingId !== null) && (
+
+ )}
+
+ {!showAddForm && editingId === null && error && (
+ {error}
+ )}
+
+ {definitions.length === 0 && !showAddForm ? (
+
+ No recurring expenses yet. Add fixed monthly costs like rent or EMI.
+
+ ) : (
+
+ {definitions.map((def) => (
+
+
+
{def.title}
+
+ Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
+
+
+
+
{formatCurrencyFromCents(def.amountCents)}
+ {editingId !== def.id && (
+ <>
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/site-nav.tsx b/src/components/site-nav.tsx
index a30cadb..c08c1e7 100644
--- a/src/components/site-nav.tsx
+++ b/src/components/site-nav.tsx
@@ -1,4 +1,7 @@
+"use client";
+
import Link from "next/link";
+import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Dashboard" },
@@ -7,17 +10,26 @@ const links = [
];
export function SiteNav() {
+ const pathname = usePathname();
+
return (
);
}
diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts
index f048c27..be59ade 100644
--- a/src/lib/dashboard.ts
+++ b/src/lib/dashboard.ts
@@ -10,6 +10,11 @@ import {
isDateInMonth,
} from "@/lib/date";
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
+import {
+ getProjectedRecurringExpenses,
+ listActiveRecurringExpenses,
+ type ProjectedRecurringExpense,
+} from "@/lib/recurring-expenses";
export type DashboardSnapshot = {
month: string;
@@ -30,7 +35,7 @@ export type DashboardSnapshot = {
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
};
categoryBreakdown: Array<{ category: string; amountCents: number }>;
- recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
+ recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string; isRecurring?: true }>;
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: {
amountCents: number;
@@ -45,8 +50,10 @@ export function buildDashboardSnapshot(input: {
paychecks: Paycheck[];
paySchedule?: PaySchedule | null;
insight?: MonthlyInsight | null;
+ projectedRecurring?: ProjectedRecurringExpense[];
}): DashboardSnapshot {
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
+ const recurringExpenses = input.projectedRecurring ?? [];
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
@@ -63,7 +70,12 @@ export function buildDashboardSnapshot(input: {
...projectedPaychecks,
];
- const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
+ const allMonthExpenses = [
+ ...monthExpenses.map((e) => ({ ...e, isRecurring: undefined as true | undefined })),
+ ...recurringExpenses,
+ ];
+
+ const expensesCents = allMonthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
const paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
const netCashFlowCents = paychecksCents - expensesCents;
@@ -72,7 +84,7 @@ export function buildDashboardSnapshot(input: {
: getDaysInMonth(input.month);
const categoryTotals = new Map();
- for (const expense of monthExpenses) {
+ for (const expense of allMonthExpenses) {
categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
}
@@ -83,13 +95,13 @@ export function buildDashboardSnapshot(input: {
const highestCategory = categoryBreakdown[0] ?? null;
const largestExpense =
- monthExpenses
+ allMonthExpenses
.slice()
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
const dailyMap = new Map();
- for (const expense of monthExpenses) {
+ for (const expense of allMonthExpenses) {
const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
current.expensesCents += expense.amountCents;
dailyMap.set(expense.date, current);
@@ -136,9 +148,16 @@ export function buildDashboardSnapshot(input: {
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
: null,
categoryBreakdown,
- recentExpenses: monthExpenses
+ recentExpenses: allMonthExpenses
.slice()
- .sort((left, right) => right.date.localeCompare(left.date) || right.createdAt.getTime() - left.createdAt.getTime())
+ .sort((left, right) => {
+ const dateDiff = right.date.localeCompare(left.date);
+ if (dateDiff !== 0) return dateDiff;
+ // Real expenses have createdAt; projected recurring don't — sort them after real ones
+ const leftTime = "createdAt" in left ? (left.createdAt as Date).getTime() : 0;
+ const rightTime = "createdAt" in right ? (right.createdAt as Date).getTime() : 0;
+ return rightTime - leftTime;
+ })
.slice(0, 6)
.map((expense) => ({
id: expense.id,
@@ -146,18 +165,22 @@ export function buildDashboardSnapshot(input: {
amountCents: expense.amountCents,
date: expense.date,
category: expense.category,
+ ...(expense.isRecurring ? { isRecurring: true as const } : {}),
})),
chart,
};
}
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
- const [expenses, paychecks, insight, paySchedule] = await Promise.all([
+ const [expenses, paychecks, insight, paySchedule, recurringDefinitions] = await Promise.all([
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
db.monthlyInsight.findUnique({ where: { month } }),
getActiveSchedule(),
+ listActiveRecurringExpenses(),
]);
- return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight });
+ const projectedRecurring = getProjectedRecurringExpenses(recurringDefinitions, month);
+
+ return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight, projectedRecurring });
}
diff --git a/src/lib/recurring-expenses.ts b/src/lib/recurring-expenses.ts
new file mode 100644
index 0000000..3cf69c3
--- /dev/null
+++ b/src/lib/recurring-expenses.ts
@@ -0,0 +1,87 @@
+import type { Category } from "@prisma/client";
+
+import { db } from "@/lib/db";
+
+export type RecurringExpense = {
+ id: string;
+ title: string;
+ amountCents: number;
+ category: string;
+ dayOfMonth: number;
+ active: boolean;
+};
+
+export type ProjectedRecurringExpense = {
+ id: string;
+ recurringId: string;
+ title: string;
+ amountCents: number;
+ category: string;
+ date: string;
+ isRecurring: true;
+};
+
+export async function listActiveRecurringExpenses(): Promise {
+ return db.recurringExpense.findMany({
+ where: { active: true },
+ orderBy: { createdAt: "asc" },
+ });
+}
+
+export async function createRecurringExpense(input: {
+ title: string;
+ amountCents: number;
+ category: Category;
+ dayOfMonth: number;
+}): Promise {
+ return db.recurringExpense.create({
+ data: {
+ title: input.title.trim(),
+ amountCents: input.amountCents,
+ category: input.category,
+ dayOfMonth: input.dayOfMonth,
+ },
+ });
+}
+
+export async function updateRecurringExpense(
+ id: string,
+ input: { title: string; amountCents: number; category: Category; dayOfMonth: number },
+): Promise {
+ return db.recurringExpense.update({
+ where: { id },
+ data: {
+ title: input.title.trim(),
+ amountCents: input.amountCents,
+ category: input.category,
+ dayOfMonth: input.dayOfMonth,
+ },
+ });
+}
+
+export async function deactivateRecurringExpense(id: string): Promise {
+ await db.recurringExpense.update({ where: { id }, data: { active: false } });
+}
+
+/**
+ * Pure function — no DB access. Projects recurring expense definitions into
+ * virtual expense objects for the given month (YYYY-MM).
+ */
+export function getProjectedRecurringExpenses(
+ definitions: RecurringExpense[],
+ month: string,
+): ProjectedRecurringExpense[] {
+ return definitions.map((def) => {
+ const day = String(def.dayOfMonth).padStart(2, "0");
+ const date = `${month}-${day}`;
+ return {
+ id: `recurring-${def.id}-${month}`,
+ recurringId: def.id,
+ title: def.title,
+ amountCents: def.amountCents,
+ category: def.category,
+ date,
+ isRecurring: true as const,
+ };
+ });
+}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index aecfe5d..ffa7b9e 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -35,5 +35,17 @@ export const monthQuerySchema = z.object({
month: z.string().refine(isValidMonthKey, "Use a YYYY-MM month."),
});
+export const recurringExpenseInputSchema = z.object({
+ title: z.string().trim().min(1, "Title is required.").max(80, "Keep titles under 80 characters."),
+ amount: amountSchema,
+ dayOfMonth: z
+ .number({ invalid_type_error: "Day must be a number." })
+ .int()
+ .min(1, "Day must be at least 1.")
+ .max(28, "Day must be 28 or less (works across all months)."),
+ category: z.nativeEnum(Category, { message: "Choose a valid category." }),
+});
+
export type ExpenseInput = z.infer;
export type PaycheckInput = z.infer;
+export type RecurringExpenseInput = z.infer;