phase 7: income, payday. and budget handling routes simplified and compacted
This commit is contained in:
219
api/src/routes/budget.ts
Normal file
219
api/src/routes/budget.ts
Normal 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;
|
||||
@@ -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
139
api/src/routes/income.ts
Normal 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
140
api/src/routes/payday.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
100
docs/api-phase6-move-log.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user