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 RUN npm ci
COPY . . 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 FROM node:22-bookworm-slim AS runner
WORKDIR /app WORKDIR /app

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ serverExternalPackages: ["@prisma/client"],
}; };
export default nextConfig; 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 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 { model MonthlyInsight {
id String @id @default(cuid()) id String @id @default(cuid())
month String @unique month String @unique

View File

@@ -1,4 +1,5 @@
import { ExpenseWorkspace } from "@/components/expense-workspace"; import { ExpenseWorkspace } from "@/components/expense-workspace";
import { RecurringExpenseManager } from "@/components/recurring-expense-manager";
import { CATEGORY_OPTIONS } from "@/lib/categories"; import { CATEGORY_OPTIONS } from "@/lib/categories";
export const metadata = { export const metadata = {
@@ -16,6 +17,8 @@ export default function AddExpensePage() {
</p> </p>
</header> </header>
<RecurringExpenseManager categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} /> <ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
</div> </div>
); );

View File

@@ -21,6 +21,7 @@ export async function GET(request: Request) {
return NextResponse.json(dashboard); return NextResponse.json(dashboard);
} catch (error) { } catch (error) {
console.error("[dashboard] snapshot error:", 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 { NextResponse } from "next/server";
import { isDateInMonth, isValidMonthKey } from "@/lib/date";
import { createExpense, listExpenses } from "@/lib/expenses"; import { createExpense, listExpenses } from "@/lib/expenses";
import { getProjectedRecurringExpenses, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
import { expenseInputSchema } from "@/lib/validation"; import { expenseInputSchema } from "@/lib/validation";
export async function GET() { export async function GET(request: Request) {
const expenses = await listExpenses(); const url = new URL(request.url);
return NextResponse.json({ expenses }); 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) { 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 { useEffect, useMemo, useState, type FormEvent } from "react";
import { getCategoryLabel, type CategoryValue } from "@/lib/categories"; import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
import { getCurrentMonthKey } from "@/lib/date";
import { formatCurrencyFromCents } from "@/lib/money"; import { formatCurrencyFromCents } from "@/lib/money";
type SuggestionResponse = { type SuggestionResponse = {
@@ -19,6 +20,7 @@ type ExpenseRecord = {
amountCents: number; amountCents: number;
date: string; date: string;
category: CategoryValue; category: CategoryValue;
isRecurring?: boolean;
}; };
type CategoryOption = { type CategoryOption = {
@@ -53,9 +55,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
useEffect(() => { useEffect(() => {
async function loadExpenses() { async function loadExpenses() {
const response = await fetch("/expenses", { cache: "no-store" }); const month = getCurrentMonthKey();
const payload = (await response.json()) as { expenses?: ExpenseRecord[] }; const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
setExpenses(payload.expenses ?? []); const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
setExpenses(payload?.expenses ?? []);
} }
void loadExpenses(); 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" className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
> >
<div> <div>
<p className="font-semibold text-stone-950">{expense.title}</p> <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"> <p className="mt-1 text-sm text-stone-600">
{expense.date} · {getCategoryLabel(expense.category)} {expense.date} · {getCategoryLabel(expense.category)}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p> <p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
<button {!expense.isRecurring && (
type="button" <>
onClick={() => handleEdit(expense)} <button
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" type="button"
> onClick={() => handleEdit(expense)}
Edit 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"
</button> >
<button Edit
type="button" </button>
onClick={() => handleDelete(expense.id)} <button
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" type="button"
> onClick={() => handleDelete(expense.id)}
Delete 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"
</button> >
Delete
</button>
</>
)}
</div> </div>
</article> </article>
)) ))

View File

@@ -26,7 +26,7 @@ type DashboardSnapshot = {
largestExpense: { title: string; amountCents: number; date: string; category: string } | null; largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
}; };
categoryBreakdown: Array<{ category: string; amountCents: number }>; 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 }>; chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null; paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
}; };
@@ -445,7 +445,14 @@ export function HomeDashboard() {
snapshot.recentExpenses.map((expense) => ( 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"> <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>
<p className="font-semibold text-stone-950">{expense.title}</p> <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"> <p className="mt-1 text-sm text-stone-600">
{expense.date} · {getCategoryLabel(expense.category as never)} {expense.date} · {getCategoryLabel(expense.category as never)}
</p> </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 Link from "next/link";
import { usePathname } from "next/navigation";
const links = [ const links = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
@@ -7,17 +10,26 @@ const links = [
]; ];
export function SiteNav() { export function SiteNav() {
const pathname = usePathname();
return ( return (
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700"> <nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
{links.map((link) => ( {links.map((link) => {
<Link const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
key={link.href} return (
href={link.href} <Link
className="rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950" key={link.href}
> href={link.href}
{link.label} className={
</Link> 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> </nav>
); );
} }

View File

@@ -10,6 +10,11 @@ import {
isDateInMonth, isDateInMonth,
} from "@/lib/date"; } from "@/lib/date";
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule"; import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
import {
getProjectedRecurringExpenses,
listActiveRecurringExpenses,
type ProjectedRecurringExpense,
} from "@/lib/recurring-expenses";
export type DashboardSnapshot = { export type DashboardSnapshot = {
month: string; month: string;
@@ -30,7 +35,7 @@ export type DashboardSnapshot = {
largestExpense: { title: string; amountCents: number; date: string; category: string } | null; largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
}; };
categoryBreakdown: Array<{ category: string; amountCents: number }>; 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 }>; chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: { paySchedule: {
amountCents: number; amountCents: number;
@@ -45,8 +50,10 @@ export function buildDashboardSnapshot(input: {
paychecks: Paycheck[]; paychecks: Paycheck[];
paySchedule?: PaySchedule | null; paySchedule?: PaySchedule | null;
insight?: MonthlyInsight | null; insight?: MonthlyInsight | null;
projectedRecurring?: ProjectedRecurringExpense[];
}): DashboardSnapshot { }): DashboardSnapshot {
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month)); 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)); 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 // 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, ...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 paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
const netCashFlowCents = paychecksCents - expensesCents; const netCashFlowCents = paychecksCents - expensesCents;
@@ -72,7 +84,7 @@ export function buildDashboardSnapshot(input: {
: getDaysInMonth(input.month); : getDaysInMonth(input.month);
const categoryTotals = new Map<string, number>(); 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); 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 highestCategory = categoryBreakdown[0] ?? null;
const largestExpense = const largestExpense =
monthExpenses allMonthExpenses
.slice() .slice()
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null; .sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
const dailyMap = new Map<string, { expensesCents: number; paychecksCents: number }>(); 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 }; const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
current.expensesCents += expense.amountCents; current.expensesCents += expense.amountCents;
dailyMap.set(expense.date, current); dailyMap.set(expense.date, current);
@@ -136,9 +148,16 @@ export function buildDashboardSnapshot(input: {
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates } ? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
: null, : null,
categoryBreakdown, categoryBreakdown,
recentExpenses: monthExpenses recentExpenses: allMonthExpenses
.slice() .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) .slice(0, 6)
.map((expense) => ({ .map((expense) => ({
id: expense.id, id: expense.id,
@@ -146,18 +165,22 @@ export function buildDashboardSnapshot(input: {
amountCents: expense.amountCents, amountCents: expense.amountCents,
date: expense.date, date: expense.date,
category: expense.category, category: expense.category,
...(expense.isRecurring ? { isRecurring: true as const } : {}),
})), })),
chart, chart,
}; };
} }
export async function getDashboardSnapshot(month = getCurrentMonthKey()) { 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.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }), db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
db.monthlyInsight.findUnique({ where: { month } }), db.monthlyInsight.findUnique({ where: { month } }),
getActiveSchedule(), 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."), 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 ExpenseInput = z.infer<typeof expenseInputSchema>;
export type PaycheckInput = z.infer<typeof paycheckInputSchema>; export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
export type RecurringExpenseInput = z.infer<typeof recurringExpenseInputSchema>;