Add recurring expenses with active nav tab highlighting

- Add RecurringExpense model to Prisma schema with migration
- Add lib/recurring-expenses.ts: CRUD + virtual projection per month
- Add /recurring-expenses API routes (GET, POST, PATCH, DELETE)
- Merge projected recurring expenses into dashboard totals and expense list
- Add RecurringExpenseManager component to /add-expense page
- Show amber "Recurring" badge on projected items; hide edit/delete for them
- Highlight active nav tab using usePathname() with hover state
- Fix Turbopack/Prisma stub issue by adding serverExternalPackages to next.config.ts
- Clear stale Turbopack stub in Dockerfile before each build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:08:38 -04:00
parent 3e6231b654
commit 5f2111ea66
16 changed files with 604 additions and 44 deletions

View File

@@ -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

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["@prisma/client"],
};
export default nextConfig;

View File

@@ -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
);

View File

@@ -50,6 +50,17 @@ model MerchantCorrection {
updatedAt DateTime @updatedAt
}
model RecurringExpense {
id String @id @default(cuid())
title String
amountCents Int
category Category
dayOfMonth Int // 128, 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

View File

@@ -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() {
</p>
</header>
<RecurringExpenseManager categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
</div>
);

View File

@@ -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 });
}
}

View File

@@ -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() {
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) {

View File

@@ -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;
}
}

View File

@@ -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 });
}

View File

@@ -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,13 +338,22 @@ 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"
>
<div>
<div className="flex items-center gap-2">
<p className="font-semibold text-stone-950">{expense.title}</p>
{expense.isRecurring && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
Recurring
</span>
)}
</div>
<p className="mt-1 text-sm text-stone-600">
{expense.date} · {getCategoryLabel(expense.category)}
</p>
</div>
<div className="flex items-center gap-2">
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
{!expense.isRecurring && (
<>
<button
type="button"
onClick={() => handleEdit(expense)}
@@ -356,6 +368,8 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
>
Delete
</button>
</>
)}
</div>
</article>
))

View File

@@ -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) => (
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold text-stone-950">{expense.title}</p>
{expense.isRecurring && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
Recurring
</span>
)}
</div>
<p className="mt-1 text-sm text-stone-600">
{expense.date} · {getCategoryLabel(expense.category as never)}
</p>

View File

@@ -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<RecurringDefinition[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formState, setFormState] = useState(emptyForm);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Fixed monthly costs</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recurring expenses</h2>
<p className="mt-1 text-sm text-stone-600">These appear automatically in every month without manual entry.</p>
</div>
{!showAddForm && editingId === null && (
<button
type="button"
onClick={openAdd}
className="shrink-0 rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
>
+ Add recurring
</button>
)}
</div>
{(showAddForm || editingId !== null) && (
<form
onSubmit={handleSubmit}
className="mb-5 grid gap-3 rounded-2xl border border-amber-200 bg-white p-5 md:grid-cols-2"
>
<p className="text-sm font-semibold text-stone-700 md:col-span-2">
{editingId ? "Edit recurring expense" : "New recurring expense"}
</p>
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
Title
<input
required
value={formState.title}
onChange={(e) => setFormState((s) => ({ ...s, title: e.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
placeholder="Rent, car insurance, EMI..."
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
Amount
<input
required
inputMode="decimal"
value={formState.amount}
onChange={(e) => setFormState((s) => ({ ...s, amount: e.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
placeholder="1200.00"
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
Day of month
<input
required
type="number"
min={1}
max={28}
value={formState.dayOfMonth}
onChange={(e) => setFormState((s) => ({ ...s, dayOfMonth: e.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
placeholder="1"
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
Category
<select
value={formState.category}
onChange={(e) => setFormState((s) => ({ ...s, category: e.target.value as CategoryValue }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
>
{categoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-rose-700">{error}</p>
<div className="flex gap-2">
<button
type="button"
onClick={cancelForm}
className="rounded-full border border-stone-300 px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
>
Cancel
</button>
<button
type="submit"
disabled={busy}
className="rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{busy ? "Saving..." : editingId ? "Update" : "Save"}
</button>
</div>
</div>
</form>
)}
{!showAddForm && editingId === null && error && (
<p className="mb-3 text-sm text-rose-700">{error}</p>
)}
{definitions.length === 0 && !showAddForm ? (
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600">
No recurring expenses yet. Add fixed monthly costs like rent or EMI.
</div>
) : (
<div className="space-y-2">
{definitions.map((def) => (
<article
key={def.id}
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
editingId === def.id
? "border-amber-300 bg-amber-100/50"
: "border-amber-200 bg-white"
}`}
>
<div>
<p className="font-semibold text-stone-950">{def.title}</p>
<p className="mt-1 text-sm text-stone-600">
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
</p>
</div>
<div className="flex items-center gap-2">
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(def.amountCents)}</p>
{editingId !== def.id && (
<>
<button
type="button"
onClick={() => openEdit(def)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
>
Edit
</button>
<button
type="button"
disabled={busy}
onClick={() => void handleDelete(def.id)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
>
Delete
</button>
</>
)}
</div>
</article>
))}
</div>
)}
</section>
);
}

View File

@@ -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 (
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
{links.map((link) => (
{links.map((link) => {
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className="rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
className={
isActive
? "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-white transition hover:bg-stone-700 hover:border-stone-700"
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
}
>
{link.label}
</Link>
))}
);
})}
</nav>
);
}

View File

@@ -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<string, number>();
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<string, { expensesCents: number; paychecksCents: number }>();
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 });
}

View File

@@ -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<RecurringExpense[]> {
return db.recurringExpense.findMany({
where: { active: true },
orderBy: { createdAt: "asc" },
});
}
export async function createRecurringExpense(input: {
title: string;
amountCents: number;
category: Category;
dayOfMonth: number;
}): Promise<RecurringExpense> {
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<RecurringExpense> {
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<void> {
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,
};
});
}

View File

@@ -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<typeof expenseInputSchema>;
export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
export type RecurringExpenseInput = z.infer<typeof recurringExpenseInputSchema>;