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