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) && ( +
+

+ {editingId ? "Edit recurring expense" : "New recurring expense"} +

+ + + + + + + + + +
+

{error}

+
+ + +
+
+
+ )} + + {!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;