phase 7: income, payday. and budget handling routes simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s

This commit is contained in:
2026-03-17 22:05:17 -05:00
parent 020d55a77e
commit a8e5443b0d
9 changed files with 630 additions and 513 deletions

219
api/src/routes/budget.ts Normal file
View File

@@ -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<BudgetRoutesOptions> = 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;

View File

@@ -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,
};
});
}

139
api/src/routes/income.ts Normal file
View File

@@ -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<IncomeRoutesOptions> = 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;

140
api/src/routes/payday.ts Normal file
View File

@@ -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<PaydayRoutesOptions> = async (app, opts) => {
const logDebug = (message: string, data?: Record<string, unknown>) => {
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;

View File

@@ -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<string, unknown>) => {
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;
}

View File

@@ -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);

View File

@@ -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);

100
docs/api-phase6-move-log.md Normal file
View File

@@ -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.

View File

@@ -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)