From a8e5443b0da37f56c0b18654963049554b91e3c6 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Tue, 17 Mar 2026 22:05:17 -0500 Subject: [PATCH] phase 7: income, payday. and budget handling routes simplified and compacted --- api/src/routes/budget.ts | 219 +++++++++++++ api/src/routes/income-preview.ts | 98 ------ api/src/routes/income.ts | 139 +++++++++ api/src/routes/payday.ts | 140 +++++++++ api/src/server.ts | 428 ++------------------------ api/tests/income.integration.test.ts | 4 + api/tests/income.test.ts | 4 + docs/api-phase6-move-log.md | 100 ++++++ docs/api-refactor-lightweight-plan.md | 11 +- 9 files changed, 630 insertions(+), 513 deletions(-) create mode 100644 api/src/routes/budget.ts delete mode 100644 api/src/routes/income-preview.ts create mode 100644 api/src/routes/income.ts create mode 100644 api/src/routes/payday.ts create mode 100644 docs/api-phase6-move-log.md diff --git a/api/src/routes/budget.ts b/api/src/routes/budget.ts new file mode 100644 index 0000000..59ea3a6 --- /dev/null +++ b/api/src/routes/budget.ts @@ -0,0 +1,219 @@ +import type { FastifyPluginAsync } from "fastify"; +import { z } from "zod"; +import { allocateBudget, applyIrregularIncome } from "../allocator.js"; + +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; + +type PercentCategory = { + id: string; + percent: number; + balanceCents: bigint | null; +}; + +type ShareResult = + | { ok: true; shares: Array<{ id: string; share: number }> } + | { ok: false; reason: string }; + +type BudgetRoutesOptions = { + mutationRateLimit: RateLimitRouteOptions; + computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult; + computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult; + isProd: boolean; +}; + +const BudgetBody = z.object({ + newIncomeCents: z.number().int().nonnegative(), + fixedExpensePercentage: z.number().min(0).max(100).default(30), + postedAtISO: z.string().datetime().optional(), +}); + +const ReconcileBody = z.object({ + bankTotalCents: z.number().int().nonnegative(), +}); + +const budgetRoutes: FastifyPluginAsync = async (app, opts) => { + app.post("/budget/allocate", opts.mutationRateLimit, async (req, reply) => { + const parsed = BudgetBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid budget data" }); + } + + const userId = req.userId; + + try { + const result = await allocateBudget( + app.prisma, + userId, + parsed.data.newIncomeCents, + parsed.data.fixedExpensePercentage, + parsed.data.postedAtISO + ); + return result; + } catch (error: any) { + app.log.error( + { error, userId, body: opts.isProd ? undefined : parsed.data }, + "Budget allocation failed" + ); + return reply.code(500).send({ message: "Budget allocation failed" }); + } + }); + + app.post("/budget/fund", opts.mutationRateLimit, async (req, reply) => { + const parsed = BudgetBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid budget data" }); + } + + const userId = req.userId; + const incomeId = `onboarding-${userId}-${Date.now()}`; + + try { + const result = await applyIrregularIncome( + app.prisma, + userId, + parsed.data.newIncomeCents, + parsed.data.fixedExpensePercentage, + parsed.data.postedAtISO || new Date().toISOString(), + incomeId, + "Initial budget setup" + ); + return result; + } catch (error: any) { + app.log.error( + { error, userId, body: opts.isProd ? undefined : parsed.data }, + "Budget funding failed" + ); + return reply.code(500).send({ message: "Budget funding failed" }); + } + }); + + app.post("/budget/reconcile", opts.mutationRateLimit, async (req, reply) => { + const parsed = ReconcileBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid reconciliation data" }); + } + + const userId = req.userId; + const desiredTotal = parsed.data.bankTotalCents; + + return await app.prisma.$transaction(async (tx) => { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + }); + if (categories.length === 0) { + return reply.code(400).send({ + ok: false, + code: "NO_CATEGORIES", + message: "Create at least one expense category before reconciling.", + }); + } + + const plans = await tx.fixedPlan.findMany({ + where: { userId }, + select: { fundedCents: true, currentFundedCents: true }, + }); + const fixedFundedCents = plans.reduce( + (sum, plan) => + sum + Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n), + 0 + ); + const variableTotal = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + const currentTotal = variableTotal + fixedFundedCents; + const delta = desiredTotal - currentTotal; + + if (delta === 0) { + return { + ok: true, + deltaCents: 0, + currentTotalCents: currentTotal, + newTotalCents: currentTotal, + }; + } + + if (desiredTotal < fixedFundedCents) { + return reply.code(400).send({ + ok: false, + code: "BELOW_FIXED_FUNDED", + message: `Bank total cannot be below funded fixed expenses (${fixedFundedCents} cents).`, + }); + } + + if (delta > 0) { + const shareResult = opts.computeDepositShares(categories, delta); + if (!shareResult.ok) { + return reply.code(400).send({ + ok: false, + code: "NO_PERCENT", + message: "No category percentages available.", + }); + } + for (const share of shareResult.shares) { + if (share.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: share.id }, + data: { balanceCents: { increment: BigInt(share.share) } }, + }); + } + } else { + const amountToRemove = Math.abs(delta); + if (amountToRemove > variableTotal) { + return reply.code(400).send({ + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "Available budget is lower than the adjustment amount.", + }); + } + const shareResult = opts.computeWithdrawShares(categories, amountToRemove); + if (!shareResult.ok) { + return reply.code(400).send({ + ok: false, + code: "INSUFFICIENT_BALANCE", + message: "Available budget is lower than the adjustment amount.", + }); + } + for (const share of shareResult.shares) { + if (share.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: share.id }, + data: { balanceCents: { decrement: BigInt(share.share) } }, + }); + } + } + + await tx.transaction.create({ + data: { + userId, + occurredAt: new Date(), + kind: "balance_adjustment", + amountCents: BigInt(Math.abs(delta)), + note: + delta > 0 + ? "Balance reconciliation: increase" + : "Balance reconciliation: decrease", + isReconciled: true, + }, + }); + + return { + ok: true, + deltaCents: delta, + currentTotalCents: currentTotal, + newTotalCents: desiredTotal, + }; + }); + }); +}; + +export default budgetRoutes; diff --git a/api/src/routes/income-preview.ts b/api/src/routes/income-preview.ts deleted file mode 100644 index b0476b7..0000000 --- a/api/src/routes/income-preview.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { z } from "zod"; -import { previewAllocation, previewIrregularAllocation } from "../allocator.js"; - -const Body = z.object({ - amountCents: z.number().int().nonnegative(), - occurredAtISO: z.string().datetime().optional(), -}); - -export default async function incomePreviewRoutes(app: FastifyInstance) { - type PlanPreview = { - id: string; - name: string; - dueOn: Date; - totalCents: number; - fundedCents: number; - remainingCents: number; - daysUntilDue: number; - allocatedThisRun: number; - isCrisis: boolean; - }; - - type PreviewResult = { - fixedAllocations: Array<{ fixedPlanId: string; amountCents: number; source?: string }>; - variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>; - planStatesAfter: PlanPreview[]; - availableBudgetAfterCents: number; - remainingUnallocatedCents: number; - crisis: { active: boolean; plans: Array<{ id: string; name: string; remainingCents: number; daysUntilDue: number; priority: number; allocatedCents: number }> }; - }; - - app.post("/income/preview", async (req, reply) => { - const parsed = Body.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ message: "Invalid amount" }); - - const userId = req.userId; - const user = await app.prisma.user.findUnique({ - where: { id: userId }, - select: { incomeType: true, fixedExpensePercentage: true }, - }); - - let result: PreviewResult; - - if (user?.incomeType === "irregular") { - const rawResult = await previewIrregularAllocation( - app.prisma, - userId, - parsed.data.amountCents, - user.fixedExpensePercentage ?? 40, - parsed.data.occurredAtISO - ); - result = { - fixedAllocations: rawResult.fixedAllocations, - variableAllocations: rawResult.variableAllocations, - planStatesAfter: rawResult.planStatesAfter, - availableBudgetAfterCents: rawResult.availableBudgetCents, - remainingUnallocatedCents: rawResult.remainingBudgetCents, - crisis: rawResult.crisis, - }; - } else { - const rawResult = await previewAllocation( - app.prisma, - userId, - parsed.data.amountCents, - parsed.data.occurredAtISO - ); - result = { - fixedAllocations: rawResult.fixedAllocations, - variableAllocations: rawResult.variableAllocations, - planStatesAfter: rawResult.planStatesAfter, - availableBudgetAfterCents: rawResult.availableBudgetAfterCents, - remainingUnallocatedCents: rawResult.remainingUnallocatedCents, - crisis: rawResult.crisis, - }; - } - - const fixedPreview = result.planStatesAfter.map((p) => ({ - id: p.id, - name: p.name, - dueOn: p.dueOn.toISOString(), - totalCents: p.totalCents, - fundedCents: p.fundedCents, - remainingCents: p.remainingCents, - daysUntilDue: p.daysUntilDue, - allocatedThisRun: p.allocatedThisRun, - isCrisis: p.isCrisis, - })); - - return { - fixedAllocations: result.fixedAllocations, - variableAllocations: result.variableAllocations, - fixedPreview, - availableBudgetAfterCents: result.availableBudgetAfterCents, - crisis: result.crisis, - unallocatedCents: result.remainingUnallocatedCents, - }; - }); -} diff --git a/api/src/routes/income.ts b/api/src/routes/income.ts new file mode 100644 index 0000000..5d238d3 --- /dev/null +++ b/api/src/routes/income.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import type { FastifyPluginAsync } from "fastify"; +import { z } from "zod"; +import { + allocateIncome, + allocateIncomeManual, + previewAllocation, +} from "../allocator.js"; + +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; + +type IncomeRoutesOptions = { + mutationRateLimit: RateLimitRouteOptions; +}; + +const AllocationOverrideSchema = z.object({ + type: z.enum(["fixed", "variable"]), + id: z.string().min(1), + amountCents: z.number().int().nonnegative(), +}); + +const incomeRoutes: FastifyPluginAsync = async (app, opts) => { + app.post("/income", opts.mutationRateLimit, async (req, reply) => { + const Body = z.object({ + amountCents: z.number().int().nonnegative(), + overrides: z.array(AllocationOverrideSchema).optional(), + occurredAtISO: z.string().datetime().optional(), + note: z.string().trim().max(500).optional(), + isScheduledIncome: z.boolean().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid amount" }); + } + + const userId = req.userId; + const amountCentsNum = Math.max(0, Math.floor(parsed.data.amountCents | 0)); + const overrides = (parsed.data.overrides ?? []).filter((o) => o.amountCents > 0); + const note = parsed.data.note?.trim() ? parsed.data.note.trim() : null; + const isScheduledIncome = parsed.data.isScheduledIncome ?? false; + const postedAt = parsed.data.occurredAtISO ? new Date(parsed.data.occurredAtISO) : new Date(); + const postedAtISO = postedAt.toISOString(); + const incomeId = randomUUID(); + + if (overrides.length > 0) { + const manual = await allocateIncomeManual( + app.prisma, + userId, + amountCentsNum, + postedAtISO, + incomeId, + overrides, + note + ); + return manual; + } + + const result = await allocateIncome( + app.prisma, + userId, + amountCentsNum, + postedAtISO, + incomeId, + note, + isScheduledIncome + ); + return result; + }); + + app.get("/income/history", async (req) => { + const userId = req.userId; + const events = await app.prisma.incomeEvent.findMany({ + where: { userId }, + orderBy: { postedAt: "desc" }, + take: 5, + select: { id: true, postedAt: true, amountCents: true }, + }); + if (events.length === 0) return []; + const allocations = await app.prisma.allocation.findMany({ + where: { userId, incomeId: { in: events.map((e) => e.id) } }, + select: { incomeId: true, kind: true, amountCents: true }, + }); + const sums = new Map< + string, + { fixed: number; variable: number } + >(); + for (const alloc of allocations) { + if (!alloc.incomeId) continue; + const entry = sums.get(alloc.incomeId) ?? { fixed: 0, variable: 0 }; + const value = Number(alloc.amountCents ?? 0n); + if (alloc.kind === "fixed") entry.fixed += value; + else entry.variable += value; + sums.set(alloc.incomeId, entry); + } + return events.map((event) => { + const totals = sums.get(event.id) ?? { fixed: 0, variable: 0 }; + return { + id: event.id, + postedAt: event.postedAt, + amountCents: Number(event.amountCents ?? 0n), + fixedTotal: totals.fixed, + variableTotal: totals.variable, + }; + }); + }); + + app.post("/income/preview", async (req, reply) => { + const Body = z.object({ + amountCents: z.number().int().nonnegative(), + occurredAtISO: z.string().datetime().optional(), + isScheduledIncome: z.boolean().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid amount" }); + } + + const userId = req.userId; + const result = await previewAllocation( + app.prisma, + userId, + parsed.data.amountCents, + parsed.data.occurredAtISO, + parsed.data.isScheduledIncome ?? false + ); + + return result; + }); +}; + +export default incomeRoutes; diff --git a/api/src/routes/payday.ts b/api/src/routes/payday.ts new file mode 100644 index 0000000..c9a39e9 --- /dev/null +++ b/api/src/routes/payday.ts @@ -0,0 +1,140 @@ +import type { FastifyPluginAsync } from "fastify"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { fromZonedTime } from "date-fns-tz"; +import { getUserMidnight } from "../allocator.js"; + +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; + +type PaydayRoutesOptions = { + mutationRateLimit: RateLimitRouteOptions; + isProd: boolean; +}; + +const paydayRoutes: FastifyPluginAsync = async (app, opts) => { + const logDebug = (message: string, data?: Record) => { + if (!opts.isProd) { + app.log.info(data ?? {}, message); + } + }; + + app.get("/payday/status", async (req, reply) => { + const userId = req.userId; + const Query = z.object({ + debugNow: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + }); + const query = Query.safeParse(req.query); + + logDebug("Payday status check started", { userId }); + + const [user, paymentPlansCount] = await Promise.all([ + app.prisma.user.findUnique({ + where: { id: userId }, + select: { + incomeType: true, + incomeFrequency: true, + firstIncomeDate: true, + pendingScheduledIncome: true, + timezone: true, + }, + }), + app.prisma.fixedPlan.count({ + where: { + userId, + paymentSchedule: { not: Prisma.DbNull }, + }, + }), + ]); + + if (!user) { + if (!opts.isProd) { + app.log.warn({ userId }, "User not found"); + } + return reply.code(404).send({ message: "User not found" }); + } + + logDebug("Payday user data retrieved", { + userId, + incomeType: user.incomeType, + incomeFrequency: user.incomeFrequency, + firstIncomeDate: user.firstIncomeDate?.toISOString(), + pendingScheduledIncome: user.pendingScheduledIncome, + paymentPlansCount, + }); + + const hasPaymentPlans = paymentPlansCount > 0; + const isRegularUser = user.incomeType === "regular"; + + if (!isRegularUser || !hasPaymentPlans || !user.firstIncomeDate) { + logDebug("Payday check skipped - not applicable", { + userId, + isRegularUser, + hasPaymentPlans, + hasFirstIncomeDate: !!user.firstIncomeDate, + }); + return { + shouldShowOverlay: false, + pendingScheduledIncome: false, + nextPayday: null, + }; + } + + const { calculateNextPayday, isWithinPaydayWindow } = await import("../allocator.js"); + const userTimezone = user.timezone || "America/New_York"; + const debugNow = query.success ? query.data.debugNow : undefined; + const now = debugNow + ? fromZonedTime(new Date(`${debugNow}T00:00:00`), userTimezone) + : new Date(); + const nextPayday = calculateNextPayday( + user.firstIncomeDate, + user.incomeFrequency, + now, + userTimezone + ); + const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone); + const dayStart = getUserMidnight(userTimezone, now); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1); + const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({ + where: { + userId, + isScheduledIncome: true, + postedAt: { + gte: dayStart, + lte: dayEnd, + }, + }, + select: { id: true }, + }); + + logDebug("Payday calculation complete", { + userId, + now: now.toISOString(), + firstIncomeDate: user.firstIncomeDate.toISOString(), + nextPayday: nextPayday.toISOString(), + isPayday, + pendingScheduledIncome: user.pendingScheduledIncome, + scheduledIncomeToday: !!scheduledIncomeToday, + shouldShowOverlay: isPayday && !scheduledIncomeToday, + }); + + return { + shouldShowOverlay: isPayday && !scheduledIncomeToday, + pendingScheduledIncome: !scheduledIncomeToday, + nextPayday: nextPayday.toISOString(), + }; + }); + + app.post("/payday/dismiss", opts.mutationRateLimit, async (_req, _reply) => { + return { ok: true }; + }); +}; + +export default paydayRoutes; diff --git a/api/src/server.ts b/api/src/server.ts index f6cd196..6b08b96 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -6,9 +6,9 @@ import fastifyJwt from "@fastify/jwt"; import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto"; import nodemailer from "nodemailer"; import { env } from "./env.js"; -import { PrismaClient, Prisma } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; import { z } from "zod"; -import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly } from "./allocator.js"; +import { getUserMidnight, getUserMidnightFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { rolloverFixedPlans } from "./jobs/rollover.js"; import healthRoutes from "./routes/health.js"; @@ -18,6 +18,9 @@ import authAccountRoutes from "./routes/auth-account.js"; import variableCategoriesRoutes from "./routes/variable-categories.js"; import transactionsRoutes from "./routes/transactions.js"; import fixedPlansRoutes from "./routes/fixed-plans.js"; +import incomeRoutes from "./routes/income.js"; +import paydayRoutes from "./routes/payday.js"; +import budgetRoutes from "./routes/budget.js"; export type AppConfig = typeof env; @@ -173,12 +176,6 @@ const addMonths = (date: Date, months: number) => { return next; }; -const logDebug = (app: FastifyInstance, message: string, data?: Record) => { - if (!isProd) { - app.log.info(data ?? {}, message); - } -}; - const ensureCsrfCookie = (reply: any, existing?: string) => { const token = existing ?? randomUUID().replace(/-/g, ""); reply.setCookie(CSRF_COOKIE, token, { @@ -888,12 +885,6 @@ app.decorate("ensureUser", async (userId: string) => { } }); -const AllocationOverrideSchema = z.object({ - type: z.enum(["fixed", "variable"]), - id: z.string().min(1), - amountCents: z.number().int().nonnegative(), -}); - await app.register(sessionRoutes, { config, cookieDomain, @@ -952,6 +943,19 @@ await app.register(fixedPlansRoutes, { calculateNextDueDate, toBig, }); +await app.register(incomeRoutes, { + mutationRateLimit, +}); +await app.register(paydayRoutes, { + mutationRateLimit, + isProd, +}); +await app.register(budgetRoutes, { + mutationRateLimit, + computeDepositShares, + computeWithdrawShares, + isProd, +}); app.get("/site-access/status", async (req) => { if (!config.UNDER_CONSTRUCTION_ENABLED) { @@ -1378,402 +1382,6 @@ app.get("/crisis-status", async (req) => { }; }); -// ----- Income allocation ----- -app.post("/income", mutationRateLimit, async (req, reply) => { - const Body = z.object({ - amountCents: z.number().int().nonnegative(), - overrides: z.array(AllocationOverrideSchema).optional(), - occurredAtISO: z.string().datetime().optional(), - note: z.string().trim().max(500).optional(), - isScheduledIncome: z.boolean().optional(), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid amount" }); - } - - const userId = req.userId; - const amountCentsNum = Math.max(0, Math.floor(parsed.data.amountCents | 0)); - const overrides = (parsed.data.overrides ?? []).filter((o) => o.amountCents > 0); - const note = parsed.data.note?.trim() ? parsed.data.note.trim() : null; - const isScheduledIncome = parsed.data.isScheduledIncome ?? false; - const postedAt = parsed.data.occurredAtISO ? new Date(parsed.data.occurredAtISO) : new Date(); - const postedAtISO = postedAt.toISOString(); - const incomeId = randomUUID(); - - if (overrides.length > 0) { - const manual = await allocateIncomeManual( - app.prisma, - userId, - amountCentsNum, - postedAtISO, - incomeId, - overrides, - note - ); - return manual; - } - - const result = await allocateIncome(app.prisma, userId, amountCentsNum, postedAtISO, incomeId, note, isScheduledIncome); - return result; -}); - -app.get("/income/history", async (req) => { - const userId = req.userId; - const events = await app.prisma.incomeEvent.findMany({ - where: { userId }, - orderBy: { postedAt: "desc" }, - take: 5, - select: { id: true, postedAt: true, amountCents: true }, - }); - if (events.length === 0) return []; - const allocations = await app.prisma.allocation.findMany({ - where: { userId, incomeId: { in: events.map((e) => e.id) } }, - select: { incomeId: true, kind: true, amountCents: true }, - }); - const sums = new Map< - string, - { fixed: number; variable: number } - >(); - for (const alloc of allocations) { - if (!alloc.incomeId) continue; - const entry = sums.get(alloc.incomeId) ?? { fixed: 0, variable: 0 }; - const value = Number(alloc.amountCents ?? 0n); - if (alloc.kind === "fixed") entry.fixed += value; - else entry.variable += value; - sums.set(alloc.incomeId, entry); - } - return events.map((event) => { - const totals = sums.get(event.id) ?? { fixed: 0, variable: 0 }; - return { - id: event.id, - postedAt: event.postedAt, - amountCents: Number(event.amountCents ?? 0n), - fixedTotal: totals.fixed, - variableTotal: totals.variable, - }; - }); -}); - -// ----- Income preview ----- -app.post("/income/preview", async (req, reply) => { - const Body = z.object({ - amountCents: z.number().int().nonnegative(), - occurredAtISO: z.string().datetime().optional(), - isScheduledIncome: z.boolean().optional(), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid amount" }); - } - - const userId = req.userId; - const result = await previewAllocation( - app.prisma, - userId, - parsed.data.amountCents, - parsed.data.occurredAtISO, - parsed.data.isScheduledIncome ?? false - ); - - return result; -}); - -// ----- Payday Management ----- -app.get("/payday/status", async (req, reply) => { - const userId = req.userId; - const Query = z.object({ - debugNow: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - }); - const query = Query.safeParse(req.query); - - logDebug(app, "Payday status check started", { userId }); - - const [user, paymentPlansCount] = await Promise.all([ - app.prisma.user.findUnique({ - where: { id: userId }, - select: { - incomeType: true, - incomeFrequency: true, - firstIncomeDate: true, - pendingScheduledIncome: true, - timezone: true, - }, - }), - app.prisma.fixedPlan.count({ - where: { - userId, - paymentSchedule: { not: Prisma.DbNull }, - }, - }), - ]); - - if (!user) { - if (!isProd) { - app.log.warn({ userId }, "User not found"); - } - return reply.code(404).send({ message: "User not found" }); - } - - logDebug(app, "Payday user data retrieved", { - userId, - incomeType: user.incomeType, - incomeFrequency: user.incomeFrequency, - firstIncomeDate: user.firstIncomeDate?.toISOString(), - pendingScheduledIncome: user.pendingScheduledIncome, - paymentPlansCount, - }); - - // Only relevant for regular income users with payment plans - const hasPaymentPlans = paymentPlansCount > 0; - const isRegularUser = user.incomeType === "regular"; - - if (!isRegularUser || !hasPaymentPlans || !user.firstIncomeDate) { - logDebug(app, "Payday check skipped - not applicable", { - userId, - isRegularUser, - hasPaymentPlans, - hasFirstIncomeDate: !!user.firstIncomeDate, - }); - return { - shouldShowOverlay: false, - pendingScheduledIncome: false, - nextPayday: null, - }; - } - - // Calculate next expected payday using the imported function with user's timezone - const { calculateNextPayday, isWithinPaydayWindow } = await import("./allocator.js"); - const userTimezone = user.timezone || "America/New_York"; - const debugNow = query.success ? query.data.debugNow : undefined; - const now = debugNow - ? fromZonedTime(new Date(`${debugNow}T00:00:00`), userTimezone) - : new Date(); - const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone); - const isPayday = isWithinPaydayWindow(now, nextPayday, 0, userTimezone); - const dayStart = getUserMidnight(userTimezone, now); - const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000 - 1); - const scheduledIncomeToday = await app.prisma.incomeEvent.findFirst({ - where: { - userId, - isScheduledIncome: true, - postedAt: { - gte: dayStart, - lte: dayEnd, - }, - }, - select: { id: true }, - }); - - logDebug(app, "Payday calculation complete", { - userId, - now: now.toISOString(), - firstIncomeDate: user.firstIncomeDate.toISOString(), - nextPayday: nextPayday.toISOString(), - isPayday, - pendingScheduledIncome: user.pendingScheduledIncome, - scheduledIncomeToday: !!scheduledIncomeToday, - shouldShowOverlay: isPayday && !scheduledIncomeToday, - }); - - return { - shouldShowOverlay: isPayday && !scheduledIncomeToday, - pendingScheduledIncome: !scheduledIncomeToday, - nextPayday: nextPayday.toISOString(), - }; -}); - -app.post("/payday/dismiss", mutationRateLimit, async (req, reply) => { - return { ok: true }; -}); - -// ----- Budget allocation (for irregular income) ----- -const BudgetBody = z.object({ - newIncomeCents: z.number().int().nonnegative(), - fixedExpensePercentage: z.number().min(0).max(100).default(30), - postedAtISO: z.string().datetime().optional(), -}); - -app.post("/budget/allocate", mutationRateLimit, async (req, reply) => { - const parsed = BudgetBody.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid budget data" }); - } - - const userId = req.userId; - - try { - const result = await allocateBudget( - app.prisma, - userId, - parsed.data.newIncomeCents, - parsed.data.fixedExpensePercentage, - parsed.data.postedAtISO - ); - return result; - } catch (error: any) { - app.log.error( - { error, userId, body: isProd ? undefined : parsed.data }, - "Budget allocation failed" - ); - return reply.code(500).send({ message: "Budget allocation failed" }); - } -}); - -// Endpoint for irregular income onboarding - actually funds accounts -app.post("/budget/fund", mutationRateLimit, async (req, reply) => { - const parsed = BudgetBody.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid budget data" }); - } - - const userId = req.userId; - const incomeId = `onboarding-${userId}-${Date.now()}`; - - try { - const result = await applyIrregularIncome( - app.prisma, - userId, - parsed.data.newIncomeCents, - parsed.data.fixedExpensePercentage, - parsed.data.postedAtISO || new Date().toISOString(), - incomeId, - "Initial budget setup" - ); - return result; - } catch (error: any) { - app.log.error( - { error, userId, body: isProd ? undefined : parsed.data }, - "Budget funding failed" - ); - return reply.code(500).send({ message: "Budget funding failed" }); - } -}); - -const ReconcileBody = z.object({ - bankTotalCents: z.number().int().nonnegative(), -}); - -app.post("/budget/reconcile", mutationRateLimit, async (req, reply) => { - const parsed = ReconcileBody.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid reconciliation data" }); - } - - const userId = req.userId; - const desiredTotal = parsed.data.bankTotalCents; - - return await app.prisma.$transaction(async (tx) => { - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - }); - if (categories.length === 0) { - return reply.code(400).send({ - ok: false, - code: "NO_CATEGORIES", - message: "Create at least one expense category before reconciling.", - }); - } - - const plans = await tx.fixedPlan.findMany({ - where: { userId }, - select: { fundedCents: true, currentFundedCents: true }, - }); - const fixedFundedCents = plans.reduce( - (sum, plan) => - sum + Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n), - 0 - ); - const variableTotal = categories.reduce( - (sum, cat) => sum + Number(cat.balanceCents ?? 0n), - 0 - ); - const currentTotal = variableTotal + fixedFundedCents; - const delta = desiredTotal - currentTotal; - - if (delta === 0) { - return { - ok: true, - deltaCents: 0, - currentTotalCents: currentTotal, - newTotalCents: currentTotal, - }; - } - - if (desiredTotal < fixedFundedCents) { - return reply.code(400).send({ - ok: false, - code: "BELOW_FIXED_FUNDED", - message: `Bank total cannot be below funded fixed expenses (${fixedFundedCents} cents).`, - }); - } - - if (delta > 0) { - const shareResult = computeDepositShares(categories, delta); - if (!shareResult.ok) { - return reply.code(400).send({ - ok: false, - code: "NO_PERCENT", - message: "No category percentages available.", - }); - } - for (const share of shareResult.shares) { - if (share.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: share.id }, - data: { balanceCents: { increment: BigInt(share.share) } }, - }); - } - } else { - const amountToRemove = Math.abs(delta); - if (amountToRemove > variableTotal) { - return reply.code(400).send({ - ok: false, - code: "INSUFFICIENT_BALANCE", - message: "Available budget is lower than the adjustment amount.", - }); - } - const shareResult = computeWithdrawShares(categories, amountToRemove); - if (!shareResult.ok) { - return reply.code(400).send({ - ok: false, - code: "INSUFFICIENT_BALANCE", - message: "Available budget is lower than the adjustment amount.", - }); - } - for (const share of shareResult.shares) { - if (share.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: share.id }, - data: { balanceCents: { decrement: BigInt(share.share) } }, - }); - } - } - - await tx.transaction.create({ - data: { - userId, - occurredAt: new Date(), - kind: "balance_adjustment", - amountCents: BigInt(Math.abs(delta)), - note: - delta > 0 - ? "Balance reconciliation: increase" - : "Balance reconciliation: decrease", - isReconciled: true, - }, - }); - - return { - ok: true, - deltaCents: delta, - currentTotalCents: currentTotal, - newTotalCents: desiredTotal, - }; - }); -}); - return app; } diff --git a/api/tests/income.integration.test.ts b/api/tests/income.integration.test.ts index bb47d9b..bd89dd7 100644 --- a/api/tests/income.integration.test.ts +++ b/api/tests/income.integration.test.ts @@ -3,6 +3,7 @@ import request from "supertest"; import { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; import { resetUser } from "./helpers"; +import { randomUUID } from "node:crypto"; // Ensure env BEFORE importing the server process.env.NODE_ENV = process.env.NODE_ENV || "test"; @@ -12,6 +13,7 @@ process.env.DATABASE_URL = const prisma = new PrismaClient(); let app: FastifyInstance; +const csrf = randomUUID().replace(/-/g, ""); beforeAll(async () => { // dynamic import AFTER env is set @@ -61,6 +63,8 @@ describe("POST /income integration", () => { const res = await request(app.server) .post("/income") .set("x-user-id", "demo-user-1") + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ amountCents: 5000 }); expect(res.status).toBe(200); diff --git a/api/tests/income.test.ts b/api/tests/income.test.ts index cf7606b..3a9eaf4 100644 --- a/api/tests/income.test.ts +++ b/api/tests/income.test.ts @@ -4,8 +4,10 @@ import { describe, it, expect, beforeEach, afterAll, beforeAll } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; +import { randomUUID } from "node:crypto"; let app: FastifyInstance; +const csrf = randomUUID().replace(/-/g, ""); beforeAll(async () => { app = await appFactory(); // <-- await the app @@ -51,6 +53,8 @@ describe("POST /income", () => { const res = await request(app.server) .post("/income") .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`) .send({ amountCents: 15000 }); expect(res.statusCode).toBe(200); diff --git a/docs/api-phase6-move-log.md b/docs/api-phase6-move-log.md new file mode 100644 index 0000000..f0e1591 --- /dev/null +++ b/docs/api-phase6-move-log.md @@ -0,0 +1,100 @@ +# API Phase 6 Move Log + +Date: 2026-03-17 +Scope: Move `income`, `budget`, and `payday` endpoints out of `api/src/server.ts` into dedicated route modules. + +## Route Registration Changes +- Added income route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:21) +- Added payday route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:22) +- Added budget route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:23) +- Registered income routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:946) +- Registered payday routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:949) +- Registered budget routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:953) +- Removed inline route blocks from `server.ts` to avoid duplicate registration: + - `POST /income` + - `GET /income/history` + - `POST /income/preview` + - `GET /payday/status` + - `POST /payday/dismiss` + - `POST /budget/allocate` + - `POST /budget/fund` + - `POST /budget/reconcile` + +## Endpoint Movements + +1. `POST /income` +- Original: `server.ts` line 1382 +- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:31) +- References: + - [useIncome.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncome.ts:27) + - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:71) + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:479) + - [income.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.test.ts:19) + - [income.integration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/income.integration.test.ts:59) + +2. `GET /income/history` +- Original: `server.ts` line 1421 +- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:78) +- References: + - [useIncomeHistory.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomeHistory.ts:15) + +3. `POST /income/preview` +- Original: `server.ts` line 1459 +- Moved to [income.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/income.ts:115) +- References: + - [useIncomePreview.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useIncomePreview.ts:16) + +4. `GET /payday/status` +- Original: `server.ts` line 1483 +- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:29) +- References: + - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:35) + +5. `POST /payday/dismiss` +- Original: `server.ts` line 1586 +- Moved to [payday.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/payday.ts:135) +- References: + - [PaydayOverlay.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/PaydayOverlay.tsx:54) + +6. `POST /budget/allocate` +- Original: `server.ts` line 1597 +- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:43) +- References: + - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:58) + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:8) + +7. `POST /budget/fund` +- Original: `server.ts` line 1624 +- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:69) +- References: + - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:65) + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:473) + +8. `POST /budget/reconcile` +- Original: `server.ts` line 1657 +- Moved to [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/budget.ts:98) +- References: + - [budget.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/budget.ts:72) + - [ReconcileSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/ReconcileSettings.tsx:8) + +## Helper Ownership in Phase 6 +- Shared helper injection from `server.ts`: + - `mutationRateLimit` + - `computeDepositShares` + - `computeWithdrawShares` + - `isProd` flag for environment-sensitive logging +- Route-local schemas/helpers: + - `income.ts`: `AllocationOverrideSchema` + - `budget.ts`: `BudgetBody`, `ReconcileBody` + - `payday.ts`: local debug logger and query schema + +## Notes +- Removed legacy unregistered `api/src/routes/income-preview.ts` to avoid duplicate endpoint logic drift. + +## Verification +1. Build +- `cd api && npm run build` ✅ + +2. Focused tests +- `cd api && npm run test -- tests/income.test.ts tests/income.integration.test.ts` +- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions. diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md index d1b7e5d..4809e9c 100644 --- a/docs/api-refactor-lightweight-plan.md +++ b/docs/api-refactor-lightweight-plan.md @@ -4,13 +4,14 @@ Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves. Current state (2026-03-17): -- `server.ts` still holds most business routes, but Phases 1-5 are complete. +- `server.ts` still holds most business routes, but Phases 1-6 are complete. - Completed move logs: - `docs/api-phase1-move-log.md` - `docs/api-phase2-move-log.md` - `docs/api-phase3-move-log.md` - `docs/api-phase4-move-log.md` - `docs/api-phase5-move-log.md` + - `docs/api-phase6-move-log.md` ## Refactor Guardrails 1. Keep route behavior identical while moving code. @@ -61,12 +62,12 @@ Completed: 3. Phase 3: `variable-categories` endpoints. 4. Phase 4: `transactions` endpoints. 5. Phase 5: `fixed-plans` endpoints. +6. Phase 6: `income`, `budget`, `payday` endpoints. Remaining: -1. Phase 6: `income`, `budget`, `payday` endpoints. -2. Phase 7: `dashboard` + `crisis-status`. -3. Phase 8: `admin` + site access endpoints. -4. Phase 9: final cleanup and helper consolidation. +1. Phase 7: `dashboard` + `crisis-status`. +2. Phase 8: `admin` + site access endpoints. +3. Phase 9: final cleanup and helper consolidation. ## Remaining Plan (Detailed)