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 { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { z } from "zod";
|
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 { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
import { rolloverFixedPlans } from "./jobs/rollover.js";
|
import { rolloverFixedPlans } from "./jobs/rollover.js";
|
||||||
import healthRoutes from "./routes/health.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 variableCategoriesRoutes from "./routes/variable-categories.js";
|
||||||
import transactionsRoutes from "./routes/transactions.js";
|
import transactionsRoutes from "./routes/transactions.js";
|
||||||
import fixedPlansRoutes from "./routes/fixed-plans.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;
|
export type AppConfig = typeof env;
|
||||||
|
|
||||||
@@ -173,12 +176,6 @@ const addMonths = (date: Date, months: number) => {
|
|||||||
return next;
|
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 ensureCsrfCookie = (reply: any, existing?: string) => {
|
||||||
const token = existing ?? randomUUID().replace(/-/g, "");
|
const token = existing ?? randomUUID().replace(/-/g, "");
|
||||||
reply.setCookie(CSRF_COOKIE, token, {
|
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, {
|
await app.register(sessionRoutes, {
|
||||||
config,
|
config,
|
||||||
cookieDomain,
|
cookieDomain,
|
||||||
@@ -952,6 +943,19 @@ await app.register(fixedPlansRoutes, {
|
|||||||
calculateNextDueDate,
|
calculateNextDueDate,
|
||||||
toBig,
|
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) => {
|
app.get("/site-access/status", async (req) => {
|
||||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import request from "supertest";
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { resetUser } from "./helpers";
|
import { resetUser } from "./helpers";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Ensure env BEFORE importing the server
|
// Ensure env BEFORE importing the server
|
||||||
process.env.NODE_ENV = process.env.NODE_ENV || "test";
|
process.env.NODE_ENV = process.env.NODE_ENV || "test";
|
||||||
@@ -12,6 +13,7 @@ process.env.DATABASE_URL =
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// dynamic import AFTER env is set
|
// dynamic import AFTER env is set
|
||||||
@@ -61,6 +63,8 @@ describe("POST /income integration", () => {
|
|||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/income")
|
.post("/income")
|
||||||
.set("x-user-id", "demo-user-1")
|
.set("x-user-id", "demo-user-1")
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ amountCents: 5000 });
|
.send({ amountCents: 5000 });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { describe, it, expect, beforeEach, afterAll, beforeAll } from "vitest";
|
|||||||
import appFactory from "./appFactory";
|
import appFactory from "./appFactory";
|
||||||
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
const csrf = randomUUID().replace(/-/g, "");
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await appFactory(); // <-- await the app
|
app = await appFactory(); // <-- await the app
|
||||||
@@ -51,6 +53,8 @@ describe("POST /income", () => {
|
|||||||
const res = await request(app.server)
|
const res = await request(app.server)
|
||||||
.post("/income")
|
.post("/income")
|
||||||
.set("x-user-id", U)
|
.set("x-user-id", U)
|
||||||
|
.set("x-csrf-token", csrf)
|
||||||
|
.set("Cookie", `csrf=${csrf}`)
|
||||||
.send({ amountCents: 15000 });
|
.send({ amountCents: 15000 });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
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.
|
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
|
||||||
|
|
||||||
Current state (2026-03-17):
|
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:
|
- Completed move logs:
|
||||||
- `docs/api-phase1-move-log.md`
|
- `docs/api-phase1-move-log.md`
|
||||||
- `docs/api-phase2-move-log.md`
|
- `docs/api-phase2-move-log.md`
|
||||||
- `docs/api-phase3-move-log.md`
|
- `docs/api-phase3-move-log.md`
|
||||||
- `docs/api-phase4-move-log.md`
|
- `docs/api-phase4-move-log.md`
|
||||||
- `docs/api-phase5-move-log.md`
|
- `docs/api-phase5-move-log.md`
|
||||||
|
- `docs/api-phase6-move-log.md`
|
||||||
|
|
||||||
## Refactor Guardrails
|
## Refactor Guardrails
|
||||||
1. Keep route behavior identical while moving code.
|
1. Keep route behavior identical while moving code.
|
||||||
@@ -61,12 +62,12 @@ Completed:
|
|||||||
3. Phase 3: `variable-categories` endpoints.
|
3. Phase 3: `variable-categories` endpoints.
|
||||||
4. Phase 4: `transactions` endpoints.
|
4. Phase 4: `transactions` endpoints.
|
||||||
5. Phase 5: `fixed-plans` endpoints.
|
5. Phase 5: `fixed-plans` endpoints.
|
||||||
|
6. Phase 6: `income`, `budget`, `payday` endpoints.
|
||||||
|
|
||||||
Remaining:
|
Remaining:
|
||||||
1. Phase 6: `income`, `budget`, `payday` endpoints.
|
1. Phase 7: `dashboard` + `crisis-status`.
|
||||||
2. Phase 7: `dashboard` + `crisis-status`.
|
2. Phase 8: `admin` + site access endpoints.
|
||||||
3. Phase 8: `admin` + site access endpoints.
|
3. Phase 9: final cleanup and helper consolidation.
|
||||||
4. Phase 9: final cleanup and helper consolidation.
|
|
||||||
|
|
||||||
## Remaining Plan (Detailed)
|
## Remaining Plan (Detailed)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user