diff --git a/openspec/changes/monthly-expense-tracker-v1/tasks.md b/openspec/changes/monthly-expense-tracker-v1/tasks.md index 9ee9b94..6f193e0 100644 --- a/openspec/changes/monthly-expense-tracker-v1/tasks.md +++ b/openspec/changes/monthly-expense-tracker-v1/tasks.md @@ -14,9 +14,9 @@ ## 3. Expense and paycheck workflows - [x] 3.1 Implement expense API routes for create, list, and delete operations. -- [ ] 3.2 Implement paycheck API routes for create, list, and delete operations. +- [x] 3.2 Implement paycheck API routes for create, list, and delete operations. - [x] 3.3 Build the `Add Expense` view with form submission, validation feedback, and expense listing. -- [ ] 3.4 Build the `Income/Paychecks` view with form submission, validation feedback, and paycheck listing. +- [x] 3.4 Build the `Income/Paychecks` view with form submission, validation feedback, and paycheck listing. ## 4. Dashboard and insights diff --git a/src/app/income/page.tsx b/src/app/income/page.tsx index f86ec4e..388fe56 100644 --- a/src/app/income/page.tsx +++ b/src/app/income/page.tsx @@ -1,15 +1,21 @@ +import { PaycheckWorkspace } from "@/components/paycheck-workspace"; + export const metadata = { title: "Income & Paychecks | Monthy Tracker", }; export default function IncomePage() { return ( -
-

Coming next

-

Paycheck tracking lands in the next implementation slice.

-

- The data model is already prepared for paychecks. This view will add create, list, and delete flows after expense tracking is validated. -

+
+
+

Income & Paychecks

+

Capture income on real pay dates, not rough monthly averages.

+

+ This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately. +

+
+ +
); } diff --git a/src/app/paychecks/[id]/route.ts b/src/app/paychecks/[id]/route.ts new file mode 100644 index 0000000..ffbfcb9 --- /dev/null +++ b/src/app/paychecks/[id]/route.ts @@ -0,0 +1,23 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { removePaycheck } from "@/lib/paychecks"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function DELETE(_: Request, context: RouteContext) { + const { id } = await context.params; + + try { + await removePaycheck(id); + return new NextResponse(null, { status: 204 }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ error: "Paycheck not found." }, { status: 404 }); + } + + throw error; + } +} diff --git a/src/app/paychecks/route.ts b/src/app/paychecks/route.ts new file mode 100644 index 0000000..3e6bb30 --- /dev/null +++ b/src/app/paychecks/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; + +import { createPaycheck, listPaychecks } from "@/lib/paychecks"; +import { paycheckInputSchema } from "@/lib/validation"; + +export async function GET() { + const paychecks = await listPaychecks(); + return NextResponse.json({ paychecks }); +} + +export async function POST(request: Request) { + const payload = await request.json(); + const parsed = paycheckInputSchema.safeParse(payload); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid paycheck payload." }, + { status: 400 }, + ); + } + + const paycheck = await createPaycheck({ + amountCents: parsed.data.amount, + payDate: parsed.data.payDate, + }); + + return NextResponse.json({ paycheck }, { status: 201 }); +} diff --git a/src/components/paycheck-workspace.tsx b/src/components/paycheck-workspace.tsx new file mode 100644 index 0000000..0e5180f --- /dev/null +++ b/src/components/paycheck-workspace.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useMemo, useState, type FormEvent } from "react"; + +import { formatCurrencyFromCents } from "@/lib/money"; + +type PaycheckRecord = { + id: string; + amountCents: number; + payDate: string; +}; + +export function PaycheckWorkspace() { + const [paychecks, setPaychecks] = useState([]); + const [formState, setFormState] = useState({ + amount: "", + payDate: new Date().toISOString().slice(0, 10), + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadPaychecks() { + const response = await fetch("/paychecks", { cache: "no-store" }); + const payload = (await response.json()) as { paychecks?: PaycheckRecord[] }; + setPaychecks(payload.paychecks ?? []); + } + + void loadPaychecks(); + }, []); + + const totalIncome = useMemo( + () => paychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0), + [paychecks], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + + const response = await fetch("/paychecks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formState), + }); + + setBusy(false); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + setError(payload?.error ?? "Could not save the paycheck."); + return; + } + + const payload = (await response.json()) as { paycheck: PaycheckRecord }; + setPaychecks((current) => [payload.paycheck, ...current]); + setFormState((current) => ({ ...current, amount: "" })); + } + + async function handleDelete(id: string) { + setBusy(true); + setError(null); + + const response = await fetch(`/paychecks/${id}`, { method: "DELETE" }); + + setBusy(false); + + if (!response.ok) { + setError("Could not delete the paycheck."); + return; + } + + setPaychecks((current) => current.filter((paycheck) => paycheck.id !== id)); + } + + return ( +
+
+
+
+

Income entry

+

Record each paycheck on the date it lands

+
+
+

Tracked paychecks

+

{formatCurrencyFromCents(totalIncome)}

+
+
+ +
+ + + + +
+

{error}

+ +
+
+
+ +
+
+

Income history

+

Recent paychecks

+
+ +
+ {paychecks.length === 0 ? ( +
+ No paychecks yet. Add the next deposit to start cash-flow tracking. +
+ ) : ( + paychecks.map((paycheck) => ( +
+
+

Paycheck

+

{paycheck.payDate}

+
+
+

{formatCurrencyFromCents(paycheck.amountCents)}

+ +
+
+ )) + )} +
+
+
+ ); +} diff --git a/src/lib/paychecks.test.ts b/src/lib/paychecks.test.ts new file mode 100644 index 0000000..8150882 --- /dev/null +++ b/src/lib/paychecks.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { paycheckInputSchema } from "@/lib/validation"; + +describe("paycheckInputSchema", () => { + it("accepts valid paycheck payloads", () => { + const parsed = paycheckInputSchema.parse({ + amount: "1800.00", + payDate: "2026-03-20", + }); + + expect(parsed.amount).toBe(180000); + }); + + it("rejects invalid pay dates", () => { + const parsed = paycheckInputSchema.safeParse({ + amount: "1800.00", + payDate: "2026-02-31", + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/lib/paychecks.ts b/src/lib/paychecks.ts new file mode 100644 index 0000000..b2f7196 --- /dev/null +++ b/src/lib/paychecks.ts @@ -0,0 +1,20 @@ +import { db } from "@/lib/db"; + +export async function listPaychecks() { + return db.paycheck.findMany({ + orderBy: [{ payDate: "desc" }, { createdAt: "desc" }], + }); +} + +export async function createPaycheck(input: { amountCents: number; payDate: string }) { + return db.paycheck.create({ + data: { + amountCents: input.amountCents, + payDate: input.payDate, + }, + }); +} + +export async function removePaycheck(id: string) { + return db.paycheck.delete({ where: { id } }); +}