Files
SkyMoney/api/src/server.ts

3532 lines
112 KiB
TypeScript

import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2";
import { randomUUID } from "node:crypto";
import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client";
import { z } from "zod";
import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { rolloverFixedPlans } from "./jobs/rollover.js";
export type AppConfig = typeof env;
const openPaths = new Set(["/health", "/health/db", "/auth/login", "/auth/register"]);
const mutationRateLimit = {
config: {
rateLimit: {
max: 60,
timeWindow: 60_000,
},
},
};
const pathOf = (url: string) => (url.split("?")[0] || "/");
const CSRF_COOKIE = "csrf";
const CSRF_HEADER = "x-csrf-token";
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 19_456,
timeCost: 3,
parallelism: 1,
};
declare module "fastify" {
interface FastifyInstance {
prisma: PrismaClient;
ensureUser(userId: string): Promise<void>;
}
interface FastifyRequest {
userId: string;
}
}
export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<FastifyInstance> {
const config = { ...env, ...overrides } as AppConfig;
const isProd = config.NODE_ENV === "production";
const cookieDomain = config.COOKIE_DOMAIN || undefined;
const app = Fastify({
logger: true,
requestIdHeader: "x-request-id",
genReqId: (req) => {
const hdr = req.headers["x-request-id"];
if (typeof hdr === "string" && hdr.length <= 64) return hdr;
return Math.random().toString(36).slice(2) + Date.now().toString(36);
},
});
const toBig = (n: number | string | bigint) => BigInt(n);
const parseCurrencyToCents = (value: string): number => {
const cleaned = value.replace(/[^0-9.]/g, "");
const [whole, fraction = ""] = cleaned.split(".");
const normalized =
fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
const parsed = Number.parseFloat(normalized || "0");
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
};
const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
const addMonths = (date: Date, months: number) => {
const next = new Date(date);
next.setMonth(next.getMonth() + months);
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, {
httpOnly: false,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return token;
};
/**
* Calculate the next due date based on frequency for rollover
*/
function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone: string = "UTC"): Date {
const base = getUserMidnightFromDateOnly(timezone, currentDueDate);
const zoned = toZonedTime(base, timezone);
switch (frequency) {
case "weekly":
zoned.setUTCDate(zoned.getUTCDate() + 7);
break;
case "biweekly":
zoned.setUTCDate(zoned.getUTCDate() + 14);
break;
case "monthly": {
const targetDay = zoned.getUTCDate();
const nextMonth = zoned.getUTCMonth() + 1;
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
const nextMonthIndex = nextMonth % 12;
const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate();
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
break;
}
default:
return base;
}
zoned.setUTCHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
const monthKey = (date: Date) =>
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
const monthLabel = (date: Date) =>
date.toLocaleString("en-US", { month: "short", year: "numeric" });
function buildMonthBuckets(count: number, now = new Date()) {
const buckets: Array<{ key: string; label: string; start: Date; end: Date }> = [];
const current = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
for (let i = count - 1; i >= 0; i--) {
const start = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() - i, 1));
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1));
buckets.push({ key: monthKey(start), label: monthLabel(start), start, end });
}
return buckets;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function jsonBigIntSafe(obj: unknown) {
return JSON.parse(
JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
);
}
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
function computePercentShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
balanceCents: Number(cat.balanceCents ?? 0n),
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
if (shares.some((s) => s.share > s.balanceCents)) {
return { ok: false as const, reason: "insufficient_balances" };
}
return { ok: true as const, shares };
}
function computeWithdrawShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const working = categories.map((cat) => ({
id: cat.id,
percent: cat.percent,
balanceCents: Number(cat.balanceCents ?? 0n),
share: 0,
}));
let remaining = Math.max(0, Math.floor(amountCents));
let safety = 0;
while (remaining > 0 && safety < 1000) {
safety += 1;
const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0);
if (eligible.length === 0) break;
const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0);
if (totalPercent <= 0) break;
const provisional = eligible.map((cat) => {
const raw = (remaining * cat.percent) / totalPercent;
const floored = Math.floor(raw);
return {
id: cat.id,
raw,
floored,
remainder: raw - floored,
};
});
let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0);
let leftovers = remaining - sumBase;
provisional
.slice()
.sort((a, b) => b.remainder - a.remainder)
.forEach((p) => {
if (leftovers > 0) {
p.floored += 1;
leftovers -= 1;
}
});
let allocatedThisRound = 0;
for (const p of provisional) {
const entry = working.find((w) => w.id === p.id);
if (!entry) continue;
const take = Math.min(p.floored, entry.balanceCents);
if (take > 0) {
entry.balanceCents -= take;
entry.share += take;
allocatedThisRound += take;
}
}
remaining -= allocatedThisRound;
if (allocatedThisRound === 0) break;
}
if (remaining > 0) {
return { ok: false as const, reason: "insufficient_balances" };
}
return {
ok: true as const,
shares: working.map((c) => ({ id: c.id, share: c.share })),
};
}
function computeOverdraftShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
return { ok: true as const, shares };
}
function computeDepositShares(categories: PercentCategory[], amountCents: number) {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (amountCents * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0);
shares
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((s) => {
if (remainder > 0) {
s.share += 1;
remainder -= 1;
}
});
return { ok: true as const, shares };
}
const DEFAULT_VARIABLE_CATEGORIES = [
{ name: "Essentials", percent: 50, priority: 10, isSavings: false },
{ name: "Savings", percent: 30, priority: 20, isSavings: true },
{ name: "Fun", percent: 20, priority: 30, isSavings: false },
] as const;
const DEFAULT_FIXED_PLANS = [
{ name: "Rent", totalCents: 120_000, priority: 10 },
] as const;
async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
const [catCount, planCount] = await Promise.all([
prisma.variableCategory.count({ where: { userId } }),
prisma.fixedPlan.count({ where: { userId } }),
]);
if (catCount > 0 && planCount > 0) return;
const now = new Date();
const nextDue = addMonths(new Date(now.getFullYear(), now.getMonth(), 1), 1);
await prisma.$transaction(async (tx) => {
if (catCount === 0) {
await tx.variableCategory.createMany({
data: DEFAULT_VARIABLE_CATEGORIES.map((cat, idx) => ({
userId,
name: cat.name,
percent: cat.percent,
priority: cat.priority + idx,
isSavings: cat.isSavings,
balanceCents: 0n,
})),
});
}
if (planCount === 0) {
await Promise.all(
DEFAULT_FIXED_PLANS.map((plan, idx) =>
tx.fixedPlan.create({
data: {
userId,
name: plan.name,
totalCents: toBig(plan.totalCents),
fundedCents: 0n,
currentFundedCents: 0n,
priority: plan.priority + idx,
cycleStart: now,
dueOn: nextDue,
fundingMode: "auto-on-deposit",
},
})
)
);
}
});
}
await app.register(cors, {
origin: (() => {
if (!config.CORS_ORIGIN) return true;
const allow = config.CORS_ORIGIN.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (origin, cb) => {
if (!origin) return cb(null, true);
cb(null, allow.includes(origin));
};
})(),
credentials: true,
});
await app.register(rateLimit, {
max: config.RATE_LIMIT_MAX,
timeWindow: config.RATE_LIMIT_WINDOW_MS,
hook: "onRequest",
allowList: (req) => {
const ip = (req.ip || "").replace("::ffff:", "");
return ip === "127.0.0.1" || ip === "::1";
},
});
await app.register(fastifyCookie, { secret: config.COOKIE_SECRET });
await app.register(fastifyJwt, {
secret: config.JWT_SECRET,
cookie: { cookieName: "session", signed: false },
sign: {
expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`,
},
});
{
const prisma = new PrismaClient();
app.decorate("prisma", prisma);
app.addHook("onClose", async () => prisma.$disconnect());
}
app.decorate("ensureUser", async (userId: string) => {
await app.prisma.user.upsert({
where: { id: userId },
update: {},
create: { id: userId, email: `${userId}@demo.local`, displayName: null },
});
if (config.SEED_DEFAULT_BUDGET) {
await seedDefaultBudget(app.prisma, userId);
}
});
app.addHook("onRequest", async (req, reply) => {
reply.header("x-request-id", String(req.id ?? ""));
const path = pathOf(req.url ?? "");
// Open paths don't require authentication
if (openPaths.has(path)) {
return;
}
// If auth is disabled, require x-user-id header (no more demo-user-1 fallback)
if (config.AUTH_DISABLED) {
const userIdHeader = req.headers["x-user-id"]?.toString().trim();
if (!userIdHeader) {
return reply.code(401).send({ error: "No user ID provided" });
}
req.userId = userIdHeader;
await app.ensureUser(req.userId);
return;
}
try {
const { sub } = await req.jwtVerify<{ sub: string }>();
req.userId = sub;
await app.ensureUser(req.userId);
} catch {
return reply
.code(401)
.send({
ok: false,
code: "UNAUTHENTICATED",
message: "Login required",
requestId: String(req.id ?? ""),
});
}
});
app.addHook("preHandler", async (req, reply) => {
const path = pathOf(req.url ?? "");
const method = req.method.toUpperCase();
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
return;
}
if (path === "/auth/login" || path === "/auth/register") {
return;
}
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined;
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" });
}
});
const AuthBody = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const AllocationOverrideSchema = z.object({
type: z.enum(["fixed", "variable"]),
id: z.string().min(1),
amountCents: z.number().int().nonnegative(),
});
app.post(
"/auth/register",
{
config: {
rateLimit: {
max: 10,
timeWindow: 60_000,
},
},
},
async (req, reply) => {
const parsed = AuthBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
const { email, password } = parsed.data;
const normalizedEmail = email.toLowerCase();
const existing = await app.prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing) {
return reply
.code(409)
.send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" });
}
const hash = await argon2.hash(password, HASH_OPTIONS);
const user = await app.prisma.user.create({
data: {
email: normalizedEmail,
passwordHash: hash,
displayName: email.split("@")[0] || null,
},
});
if (config.SEED_DEFAULT_BUDGET) {
await seedDefaultBudget(app.prisma, user.id);
}
const token = await reply.jwtSign({ sub: user.id });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
ensureCsrfCookie(reply);
return { ok: true };
});
app.post(
"/auth/login",
{
config: {
rateLimit: {
max: 10,
timeWindow: 60_000,
},
},
},
async (req, reply) => {
const parsed = AuthBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
const { email, password } = parsed.data;
const user = await app.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
await app.ensureUser(user.id);
const token = await reply.jwtSign({ sub: user.id });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
ensureCsrfCookie(reply);
return { ok: true };
});
app.post("/auth/logout", async (_req, reply) => {
reply.clearCookie("session", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true };
});
app.post("/auth/refresh", async (req, reply) => {
// Generate a new token to extend the session
const userId = req.userId;
const token = await reply.jwtSign({ sub: userId });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60;
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
ensureCsrfCookie(reply, (req.cookies as any)?.[CSRF_COOKIE]);
return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES };
});
app.get("/auth/session", async (req, reply) => {
if (!(req.cookies as any)?.[CSRF_COOKIE]) {
ensureCsrfCookie(reply);
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { email: true, displayName: true },
});
return {
ok: true,
userId: req.userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
};
});
app.patch("/me", async (req, reply) => {
const Body = z.object({
displayName: z.string().trim().min(1).max(120),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const updated = await app.prisma.user.update({
where: { id: req.userId },
data: { displayName: parsed.data.displayName.trim() },
select: { id: true, email: true, displayName: true },
});
return { ok: true, userId: updated.id, email: updated.email, displayName: updated.displayName };
});
app.patch("/me/password", async (req, reply) => {
const Body = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid password data" });
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { passwordHash: true },
});
if (!user?.passwordHash) {
return reply.code(401).send({ ok: false, message: "No password set" });
}
// Verify current password
const valid = await argon2.verify(user.passwordHash, parsed.data.currentPassword);
if (!valid) {
return reply.code(401).send({ ok: false, message: "Current password is incorrect" });
}
// Hash new password
const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS);
// Update password
await app.prisma.user.update({
where: { id: req.userId },
data: { passwordHash: newHash },
});
return { ok: true, message: "Password updated successfully" };
});
app.patch("/me/income-frequency", async (req, reply) => {
const Body = z.object({
incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid income frequency data" });
}
const updated = await app.prisma.user.update({
where: { id: req.userId },
data: {
incomeFrequency: parsed.data.incomeFrequency,
},
select: { id: true, incomeFrequency: true },
});
return {
ok: true,
incomeFrequency: updated.incomeFrequency,
};
});
app.addHook("preSerialization", (_req, _reply, payload, done) => {
try {
if (payload && typeof payload === "object") {
const safe = JSON.parse(
JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? Number(v) : v))
);
return done(null, safe);
}
return done(null, payload);
} catch {
return done(null, payload);
}
});
app.setErrorHandler((err, req, reply) => {
const status =
(typeof (err as any).statusCode === "number" && (err as any).statusCode) ||
(typeof (err as any).status === "number" && (err as any).status) ||
(typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500);
const body = {
ok: false,
code: (err as any).code ?? "INTERNAL",
message:
status >= 500 ? "Something went wrong" : (err as any).message ?? "Bad request",
requestId: String(req.id ?? ""),
};
req.log.error({ err, requestId: req.id }, "request failed");
reply.code(status).send(body);
});
app.setNotFoundHandler((req, reply) => {
reply.code(404).send({
ok: false,
code: "NOT_FOUND",
message: `No route: ${req.method} ${req.url}`,
});
});
app.post("/admin/rollover", async (req, reply) => {
if (!config.AUTH_DISABLED) {
return reply.code(403).send({ ok: false, message: "Forbidden" });
}
const Body = z.object({
asOf: z.string().datetime().optional(),
dryRun: z.boolean().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const asOf = parsed.data.asOf ? new Date(parsed.data.asOf) : new Date();
const dryRun = parsed.data.dryRun ?? false;
const results = await rolloverFixedPlans(app.prisma, asOf, { dryRun });
return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results };
});
// ----- Health -----
app.get("/health", async () => ({ ok: true }));
app.get("/health/db", async () => {
const start = Date.now();
const [{ now }] =
await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
const latencyMs = Date.now() - start;
return { ok: true, nowISO: now.toISOString(), latencyMs };
});
// ----- Dashboard -----
app.get("/dashboard", async (req) => {
const userId = req.userId;
const monthsBack = 6;
const buckets = buildMonthBuckets(monthsBack);
const rangeStart = buckets[0]?.start ?? new Date();
const now = new Date();
const dashboardTxKinds = ["variable_spend", "fixed_payment"];
const [cats, plans, recentTxs, agg, allocAgg, incomeEvents, spendTxs, user] = await Promise.all([
app.prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
}),
app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
}),
app.prisma.transaction.findMany({
where: { userId, kind: { in: dashboardTxKinds } },
orderBy: { occurredAt: "desc" },
take: 50,
select: { id: true, kind: true, amountCents: true, occurredAt: true },
}),
app.prisma.incomeEvent.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.allocation.aggregate({
where: { userId },
_sum: { amountCents: true },
}),
app.prisma.incomeEvent.findMany({
where: { userId, postedAt: { gte: rangeStart } },
select: { postedAt: true, amountCents: true },
}),
app.prisma.transaction.findMany({
where: {
userId,
kind: { in: dashboardTxKinds },
occurredAt: { gte: rangeStart },
},
select: { occurredAt: true, amountCents: true },
}),
app.prisma.user.findUnique({
where: { id: userId },
select: { email: true, displayName: true, incomeFrequency: true, incomeType: true, timezone: true, firstIncomeDate: true, fixedExpensePercentage: true },
}),
]);
const totalIncomeCents = Number(agg._sum?.amountCents ?? 0n);
const totalAllocatedCents = Number(allocAgg._sum?.amountCents ?? 0n);
const availableBudgetCents = Math.max(0, totalIncomeCents - totalAllocatedCents);
// Import timezone-aware helper for consistent date calculations
const { getUserMidnight, calculateNextPayday } = await import("./allocator.js");
const userTimezone = user?.timezone || "America/New_York";
const userNow = getUserMidnight(userTimezone, now);
const upcomingCutoff = new Date(userNow.getTime() + 14 * DAY_MS);
const fixedPlans = plans.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
// Use timezone-aware date comparison for consistency with allocator
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const fundedPercent = total > 0 ? (funded / total) * 100 : 100;
// Use same crisis logic as allocator for consistency
const CRISIS_MINIMUM_CENTS = 1000;
const isPaymentPlanUser = user?.incomeType === "regular" && plan.paymentSchedule !== null;
let isCrisis = false;
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && user?.firstIncomeDate) {
// Crisis if due BEFORE next payday AND not mostly funded
const nextPayday = calculateNextPayday(user.firstIncomeDate, user.incomeFrequency, now, userTimezone);
const daysUntilPayday = Math.max(0, Math.ceil((nextPayday.getTime() - userNow.getTime()) / DAY_MS));
isCrisis = daysUntilDue < daysUntilPayday && fundedPercent < 90;
} else {
// For irregular income users
isCrisis = fundedPercent < 70 && daysUntilDue <= 14;
}
}
return {
...plan,
fundedCents: funded,
currentFundedCents: funded,
remainingCents,
daysUntilDue,
percentFunded,
isCrisis,
};
});
const variableBalanceCents = Number(
cats.reduce((sum, cat) => sum + (cat.balanceCents ?? 0n), 0n)
);
const fixedFundedCents = Number(
fixedPlans.reduce((sum, plan) => sum + plan.fundedCents, 0)
);
const currentTotalBalance = variableBalanceCents + fixedFundedCents;
const totals = {
incomeCents: currentTotalBalance, // Changed: show current balance instead of lifetime income
availableBudgetCents,
variableBalanceCents,
fixedRemainingCents: Number(
fixedPlans.reduce((sum, plan) => sum + Math.max(0, plan.remainingCents), 0)
),
};
const percentTotal = cats.reduce((sum, cat) => sum + cat.percent, 0);
const incomeByMonth = new Map<string, number>();
incomeEvents.forEach((evt) => {
const key = monthKey(evt.postedAt);
incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n));
});
const spendByMonth = new Map<string, number>();
spendTxs.forEach((tx) => {
const key = monthKey(tx.occurredAt);
spendByMonth.set(key, (spendByMonth.get(key) ?? 0) + Number(tx.amountCents ?? 0n));
});
const monthlyTrend = buckets.map((bucket) => ({
monthKey: bucket.key,
label: bucket.label,
incomeCents: incomeByMonth.get(bucket.key) ?? 0,
spendCents: spendByMonth.get(bucket.key) ?? 0,
}));
const upcomingPlans = fixedPlans
.map((plan) => ({ ...plan, due: getUserMidnightFromDateOnly(userTimezone, plan.dueOn) }))
.filter(
(plan) =>
plan.remainingCents > 0 &&
plan.due >= userNow &&
plan.due <= upcomingCutoff
)
.sort((a, b) => a.due.getTime() - b.due.getTime())
.map((plan) => ({
id: plan.id,
name: plan.name,
dueOn: plan.due.toISOString(),
remainingCents: plan.remainingCents,
percentFunded: plan.percentFunded,
daysUntilDue: plan.daysUntilDue,
isCrisis: plan.isCrisis,
}));
const savingsTargets = cats
.filter((cat) => cat.isSavings && (cat.savingsTargetCents ?? 0n) > 0n)
.map((cat) => {
const target = Number(cat.savingsTargetCents ?? 0n);
const current = Number(cat.balanceCents ?? 0n);
const percent = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
return {
id: cat.id,
name: cat.name,
balanceCents: current,
targetCents: target,
percent,
};
});
const crisisAlerts = fixedPlans
.filter((plan) => plan.isCrisis && plan.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
})
.map((plan) => ({
id: plan.id,
name: plan.name,
remainingCents: plan.remainingCents,
daysUntilDue: plan.daysUntilDue,
percentFunded: plan.percentFunded,
}));
// Simplified fixed funding detection using tracking flags
function shouldFundFixedPlans(userType: string, incomeFrequency: string, fixedPlans: any[], crisisActive: boolean) {
// 1. Crisis mode = always fund fixed
if (crisisActive) return true;
// 2. Irregular users = always fund until fully funded
if (userType === "irregular") {
return fixedPlans.some(plan => {
const remaining = Number(plan.remainingCents ?? 0);
return remaining > 0;
});
}
// 3. Regular users = use simple flag-based detection
// Plans needing funding will have needsFundingThisPeriod = true
return fixedPlans.some(plan => {
const remaining = Number(plan.remainingCents ?? 0);
if (remaining <= 0) return false; // Already fully funded
// Simple check: does this plan need funding this period?
return plan.needsFundingThisPeriod === true;
});
}
const needsFixedFunding = shouldFundFixedPlans(
user?.incomeType ?? "regular",
user?.incomeFrequency ?? "biweekly",
fixedPlans,
crisisAlerts.length > 0
);
const hasBudgetSetup = cats.length > 0 && percentTotal === 100;
return {
totals,
variableCategories: cats,
fixedPlans: fixedPlans.map((plan) => ({
...plan,
dueOn: getUserMidnightFromDateOnly(userTimezone, plan.dueOn).toISOString(),
lastFundingDate: plan.lastFundingDate ? new Date(plan.lastFundingDate).toISOString() : null,
})),
recentTransactions: recentTxs,
percentTotal,
hasBudgetSetup,
user: {
id: userId,
email: user?.email ?? null,
displayName: user?.displayName ?? null,
incomeFrequency: user?.incomeFrequency ?? "biweekly",
incomeType: user?.incomeType ?? "regular",
timezone: user?.timezone ?? "America/New_York",
firstIncomeDate: user?.firstIncomeDate
? getUserMidnightFromDateOnly(userTimezone, user.firstIncomeDate).toISOString()
: null,
fixedExpensePercentage: user?.fixedExpensePercentage ?? 40,
},
monthlyTrend,
upcomingPlans,
savingsTargets,
crisis: {
active: crisisAlerts.length > 0,
plans: crisisAlerts,
},
needsFixedFunding,
};
});
app.get("/crisis-status", async (req) => {
const userId = req.userId;
const now = new Date();
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const userNow = getUserMidnight(userTimezone, now);
const plans = await app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
priority: true,
},
});
const crisisPlans = plans
.map((plan) => {
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const userDueDate = getUserMidnightFromDateOnly(userTimezone, plan.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / (24 * 60 * 60 * 1000)));
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const isCrisis = remainingCents > 0 && daysUntilDue <= 7;
return {
id: plan.id,
name: plan.name,
remainingCents,
daysUntilDue,
percentFunded,
priority: plan.priority,
isCrisis,
};
})
.filter((plan) => plan.isCrisis)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
return {
active: crisisPlans.length > 0,
plans: crisisPlans,
};
});
// ----- 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;
});
// ----- Transactions: create -----
app.post("/transactions", mutationRateLimit, async (req, reply) => {
const Body = z
.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
categoryId: z.string().uuid().optional(),
planId: z.string().uuid().optional(),
note: z.string().trim().max(500).optional(),
receiptUrl: z
.string()
.trim()
.url()
.max(2048)
.optional(),
isReconciled: z.boolean().optional(),
allowOverdraft: z.boolean().optional(), // Allow spending more than balance
useAvailableBudget: z.boolean().optional(), // Spend from total available budget
})
.superRefine((data, ctx) => {
if (data.kind === "variable_spend") {
if (!data.categoryId && !data.useAvailableBudget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "categoryId required for variable_spend",
path: ["categoryId"],
});
}
if (data.planId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "planId not allowed for variable_spend",
path: ["planId"],
});
}
}
if (data.kind === "fixed_payment") {
if (!data.planId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "planId required for fixed_payment",
path: ["planId"],
});
}
if (data.categoryId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "categoryId not allowed for fixed_payment",
path: ["categoryId"],
});
}
}
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const { kind, amountCents, occurredAtISO, categoryId, planId, note, receiptUrl, isReconciled, allowOverdraft, useAvailableBudget } = parsed.data;
const userId = req.userId;
const amt = toBig(amountCents);
return await app.prisma.$transaction(async (tx) => {
let deletePlanAfterPayment = false;
let paidAmount = amountCents;
// Track updated next due date if we modify a fixed plan
let updatedDueOn: Date | undefined;
if (kind === "variable_spend") {
if (useAvailableBudget) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
if (amountCents > availableBudget && !allowOverdraft) {
const overdraftAmount = amountCents - availableBudget;
return reply.code(400).send({
ok: false,
code: "OVERDRAFT_CONFIRMATION",
message: `This will overdraft your available budget by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`,
overdraftAmount,
categoryName: "available budget",
currentBalance: availableBudget,
});
}
const shareResult = allowOverdraft
? computeOverdraftShares(categories, amountCents)
: computeWithdrawShares(categories, amountCents);
if (!shareResult.ok) {
const err: any = new Error(
shareResult.reason === "no_percent"
? "No category percentages available."
: "Insufficient category balances to cover this spend."
);
err.statusCode = 400;
err.code =
shareResult.reason === "no_percent"
? "NO_CATEGORY_PERCENT"
: "INSUFFICIENT_CATEGORY_BALANCES";
throw err;
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
} else {
if (!categoryId) {
return reply.code(400).send({ message: "categoryId required" });
}
const cat = await tx.variableCategory.findFirst({
where: { id: categoryId, userId },
});
if (!cat) return reply.code(404).send({ message: "Category not found" });
const bal = cat.balanceCents ?? 0n;
if (amt > bal && !allowOverdraft) {
// Ask for confirmation before allowing overdraft
const overdraftAmount = Number(amt - bal);
return reply.code(400).send({
ok: false,
code: "OVERDRAFT_CONFIRMATION",
message: `This will overdraft ${cat.name} by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`,
overdraftAmount,
categoryName: cat.name,
currentBalance: Number(bal),
});
}
const updated = await tx.variableCategory.updateMany({
where: { id: cat.id, userId },
data: { balanceCents: bal - amt }, // Can go negative
});
if (updated.count === 0) {
return reply.code(404).send({ message: "Category not found" });
}
}
} else {
// fixed_payment: Either a funding contribution (default) or a reconciliation payment
if (!planId) {
return reply.code(400).send({ message: "planId required" });
}
const plan = await tx.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) return reply.code(404).send({ message: "Plan not found" });
const userTimezone =
(await tx.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const totalAmount = Number(plan.totalCents ?? 0n);
const isOneTime = !plan.frequency || plan.frequency === "one-time";
const isReconciledPayment = !!isReconciled;
if (!isReconciledPayment) {
const remainingNeeded = Math.max(0, totalAmount - fundedAmount);
const amountToFund = Math.min(amountCents, remainingNeeded);
if (amountToFund <= 0) {
return reply.code(400).send({ message: "Plan is already fully funded." });
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
if (availableBudget < amountToFund) {
const err: any = new Error("Insufficient available budget to fund this amount.");
err.statusCode = 400;
err.code = "INSUFFICIENT_AVAILABLE_BUDGET";
err.availableBudget = availableBudget;
err.shortage = amountToFund;
throw err;
}
const shareResult = computeWithdrawShares(categories, amountToFund);
if (!shareResult.ok) {
const err: any = new Error(
shareResult.reason === "no_percent"
? "No category percentages available."
: "Insufficient category balances to fund this amount."
);
err.statusCode = 400;
err.code =
shareResult.reason === "no_percent"
? "NO_CATEGORY_PERCENT"
: "INSUFFICIENT_CATEGORY_BALANCES";
throw err;
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(amountToFund),
incomeId: null,
},
});
const newFunded = fundedAmount + amountToFund;
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: BigInt(newFunded),
currentFundedCents: BigInt(newFunded),
lastFundingDate: new Date(),
lastFundedPayPeriod: new Date(),
needsFundingThisPeriod: newFunded < totalAmount,
},
});
paidAmount = amountToFund;
if (!isOneTime && newFunded >= totalAmount) {
if (plan.frequency && plan.frequency !== "one-time") {
updatedDueOn = calculateNextDueDate(plan.dueOn, plan.frequency, userTimezone);
} else {
updatedDueOn = plan.dueOn ?? undefined;
}
}
} else {
// Reconciliation: confirm a real payment
const normalizedPaid = Math.min(amountCents, totalAmount);
const shortage = Math.max(0, normalizedPaid - fundedAmount);
const effectiveFunded = fundedAmount + shortage;
if (shortage > 0) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
if (availableBudget < shortage) {
const err: any = new Error("Insufficient available budget to cover this payment.");
err.statusCode = 400;
err.code = "INSUFFICIENT_AVAILABLE_BUDGET";
err.availableBudget = availableBudget;
err.shortage = shortage;
throw err;
}
const shareResult = computeWithdrawShares(categories, shortage);
if (!shareResult.ok) {
const err: any = new Error(
shareResult.reason === "no_percent"
? "No category percentages available."
: "Insufficient category balances to cover this payment."
);
err.statusCode = 400;
err.code =
shareResult.reason === "no_percent"
? "NO_CATEGORY_PERCENT"
: "INSUFFICIENT_CATEGORY_BALANCES";
throw err;
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(shortage),
incomeId: null,
},
});
}
paidAmount = normalizedPaid;
// Reconciliation logic based on payment amount vs funded amount
if (paidAmount >= totalAmount) {
if (isOneTime) {
deletePlanAfterPayment = true;
} else {
let frequency = plan.frequency;
if (!frequency && plan.paymentSchedule) {
const schedule = plan.paymentSchedule as any;
frequency = schedule.frequency;
}
if (frequency && frequency !== "one-time") {
updatedDueOn = calculateNextDueDate(plan.dueOn, frequency, userTimezone);
} else {
updatedDueOn = plan.dueOn ?? undefined;
}
const updateData: any = {
fundedCents: 0n,
currentFundedCents: 0n,
isOverdue: false,
overdueAmount: 0n,
overdueSince: null,
needsFundingThisPeriod: plan.paymentSchedule ? true : false,
};
if (updatedDueOn) {
updateData.dueOn = updatedDueOn;
updateData.nextPaymentDate = plan.autoPayEnabled
? updatedDueOn
: null;
}
await tx.fixedPlan.update({
where: { id: plan.id },
data: updateData,
});
}
} else if (paidAmount > 0 && paidAmount < totalAmount) {
const refundAmount = Math.max(0, effectiveFunded - paidAmount);
if (refundAmount > 0) {
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(-refundAmount),
incomeId: null,
},
});
}
const remainingBalance = totalAmount - paidAmount;
const updatedPlan = await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: 0n,
currentFundedCents: 0n,
isOverdue: true,
overdueAmount: BigInt(remainingBalance),
overdueSince: plan.overdueSince ?? new Date(),
needsFundingThisPeriod: true,
},
select: { id: true, dueOn: true },
});
updatedDueOn = updatedPlan.dueOn ?? undefined;
} else {
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
isOverdue: true,
overdueAmount: BigInt(totalAmount - fundedAmount),
overdueSince: plan.overdueSince ?? new Date(),
needsFundingThisPeriod: true,
},
});
}
}
}
const row = await tx.transaction.create({
data: {
userId,
occurredAt: new Date(occurredAtISO),
kind,
amountCents: toBig(paidAmount),
categoryId: kind === "variable_spend" ? categoryId ?? null : null,
planId: kind === "fixed_payment" ? planId ?? null : null,
note: note?.trim() ? note.trim() : null,
receiptUrl: receiptUrl ?? null,
isReconciled: isReconciled ?? false,
isAutoPayment: false,
},
select: { id: true, kind: true, amountCents: true, occurredAt: true },
});
// If this was a fixed payment, include next due date info for UI toast
if (kind === "fixed_payment") {
if (deletePlanAfterPayment) {
await tx.fixedPlan.deleteMany({ where: { id: planId, userId } });
}
return {
...row,
planId,
nextDueOn: updatedDueOn || undefined,
} as any;
}
return row;
});
});
// ----- Fixed Plans: Enable Early Funding -----
app.patch("/fixed-plans/:id/early-funding", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
}
const planId = params.data.id;
const Body = z.object({
enableEarlyFunding: z.boolean(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid request", issues: parsed.error.issues });
}
const plan = await app.prisma.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
await app.prisma.fixedPlan.update({
where: { id: planId },
data: parsed.data.enableEarlyFunding
? (() => {
let nextDue = plan.dueOn;
let frequency = plan.frequency;
if (!frequency && plan.paymentSchedule) {
const schedule = plan.paymentSchedule as any;
frequency = schedule.frequency;
}
if (frequency && frequency !== "one-time") {
nextDue = calculateNextDueDate(plan.dueOn, frequency as any, userTimezone);
}
return {
fundedCents: 0n,
currentFundedCents: 0n,
needsFundingThisPeriod: true,
cycleStart: getUserMidnight(userTimezone, new Date()),
dueOn: nextDue,
lastRollover: new Date(),
};
})()
: {
needsFundingThisPeriod: false,
},
});
return reply.send({
ok: true,
planId,
needsFundingThisPeriod: parsed.data.enableEarlyFunding,
});
});
// ----- Fixed Plans: Attempt Final Funding (called when payment modal opens) -----
app.post("/fixed-plans/:id/attempt-final-funding", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
}
const planId = params.data.id;
return await app.prisma.$transaction(async (tx) => {
const plan = await tx.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const totalAmount = Number(plan.totalCents ?? 0n);
const remainingNeeded = totalAmount - fundedAmount;
// Already fully funded - no action needed
if (remainingNeeded <= 0) {
return {
ok: true,
planId,
status: "fully_funded",
fundedCents: fundedAmount,
totalCents: totalAmount,
isOverdue: false,
};
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
// Can we fully fund from available budget?
if (availableBudget >= remainingNeeded) {
const shareResult = computeWithdrawShares(categories, remainingNeeded);
if (!shareResult.ok) {
return reply.code(400).send({ message: "Insufficient category balances" });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(remainingNeeded),
},
});
await tx.fixedPlan.update({
where: { id: planId },
data: {
currentFundedCents: BigInt(totalAmount),
fundedCents: BigInt(totalAmount),
},
});
return {
ok: true,
planId,
status: "fully_funded",
fundedCents: totalAmount,
totalCents: totalAmount,
isOverdue: false,
message: `Topped off with $${(remainingNeeded / 100).toFixed(2)} from available budget`,
};
} else if (availableBudget > 0) {
const shareResult = computeWithdrawShares(categories, availableBudget);
if (!shareResult.ok) {
return reply.code(400).send({ message: "Insufficient category balances" });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(availableBudget),
},
});
const newFundedAmount = fundedAmount + availableBudget;
const overdueAmount = totalAmount - newFundedAmount;
await tx.fixedPlan.update({
where: { id: planId },
data: {
currentFundedCents: BigInt(newFundedAmount),
fundedCents: BigInt(newFundedAmount),
isOverdue: true,
overdueAmount: BigInt(overdueAmount),
overdueSince: plan.overdueSince ?? new Date(),
needsFundingThisPeriod: true,
},
});
return {
ok: true,
planId,
status: "overdue",
fundedCents: newFundedAmount,
totalCents: totalAmount,
isOverdue: true,
overdueAmount,
message: `Used all available budget ($${(availableBudget / 100).toFixed(2)}). Remaining $${(overdueAmount / 100).toFixed(2)} marked overdue.`,
};
} else {
// No available budget - mark overdue with full remaining balance
await tx.fixedPlan.update({
where: { id: planId },
data: {
isOverdue: true,
overdueAmount: BigInt(remainingNeeded),
overdueSince: plan.overdueSince ?? new Date(),
needsFundingThisPeriod: true,
},
});
return {
ok: true,
planId,
status: "overdue",
fundedCents: fundedAmount,
totalCents: totalAmount,
isOverdue: true,
overdueAmount: remainingNeeded,
message: `No available budget. $${(remainingNeeded / 100).toFixed(2)} marked overdue.`,
};
}
});
});
// ----- Fixed Plans: Mark as Overdue (Not Paid) -----
app.patch("/fixed-plans/:id/mark-unpaid", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
}
const planId = params.data.id;
const plan = await app.prisma.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const totalAmount = Number(plan.totalCents ?? 0n);
const remainingBalance = totalAmount - fundedAmount;
await app.prisma.fixedPlan.update({
where: { id: planId },
data: {
isOverdue: true,
overdueAmount: BigInt(Math.max(0, remainingBalance)),
overdueSince: plan.overdueSince ?? new Date(),
needsFundingThisPeriod: true, // Will be prioritized in next income allocation
},
});
return reply.send({
ok: true,
planId,
isOverdue: true,
overdueAmount: Math.max(0, remainingBalance),
});
});
// ----- Fixed Plans: Fund from available budget (all-or-nothing) -----
app.post("/fixed-plans/:id/fund-from-available", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
}
const planId = params.data.id;
return await app.prisma.$transaction(async (tx) => {
const plan = await tx.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const user = await tx.user.findUnique({
where: { id: userId },
select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true },
});
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const totalAmount = Number(plan.totalCents ?? 0n);
const remainingNeeded = Math.max(0, totalAmount - fundedAmount);
if (remainingNeeded <= 0) {
return {
ok: true,
planId,
funded: true,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
message: "Already fully funded",
};
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
let amountToFund = remainingNeeded;
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
if (user?.incomeType === "regular" && hasPaymentSchedule) {
const timezone = user?.timezone || "America/New_York";
const now = new Date();
const userNow = getUserMidnight(timezone, now);
const userDueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn);
let cyclesLeft = 1;
if (user?.firstIncomeDate && user?.incomeFrequency) {
cyclesLeft = countPayPeriodsBetween(
userNow,
userDueDate,
user.firstIncomeDate,
user.incomeFrequency,
timezone
);
} else if (user?.incomeFrequency) {
const freqDays =
user.incomeFrequency === "weekly"
? 7
: user.incomeFrequency === "biweekly"
? 14
: 30;
const daysUntilDue = Math.max(
0,
Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)
);
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
}
amountToFund = Math.min(remainingNeeded, Math.ceil(remainingNeeded / cyclesLeft));
}
if (availableBudget < amountToFund) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
availableBudget,
message: "Insufficient available budget",
};
}
const shareResult = computeWithdrawShares(categories, amountToFund);
if (!shareResult.ok) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
availableBudget,
message:
shareResult.reason === "no_percent"
? "No category percentages available"
: "Insufficient category balances",
};
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(amountToFund),
},
});
const newFunded = fundedAmount + amountToFund;
const stillNeedsFunding = newFunded < totalAmount;
await tx.fixedPlan.update({
where: { id: planId },
data: {
fundedCents: BigInt(newFunded),
currentFundedCents: BigInt(newFunded),
lastFundingDate: new Date(),
needsFundingThisPeriod: stillNeedsFunding,
},
});
return {
ok: true,
planId,
funded: true,
fundedAmountCents: amountToFund,
fundedCents: newFunded,
totalCents: totalAmount,
availableBudget,
message: "Funded from available budget",
};
});
});
// ----- Fixed Plans: Catch up funding based on payment plan progress -----
app.post("/fixed-plans/:id/catch-up-funding", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
}
const planId = params.data.id;
return await app.prisma.$transaction(async (tx) => {
const plan = await tx.fixedPlan.findFirst({
where: { id: planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Plan not found" });
}
const user = await tx.user.findUnique({
where: { id: userId },
select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true },
});
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
if (!hasPaymentSchedule || user?.incomeType !== "regular") {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n),
totalCents: Number(plan.totalCents ?? 0n),
message: "No payment plan to catch up",
};
}
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const totalAmount = Number(plan.totalCents ?? 0n);
if (totalAmount <= 0) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
message: "No amount to fund",
};
}
const timezone = user?.timezone || "America/New_York";
const now = new Date();
let cycleStart = getUserMidnightFromDateOnly(timezone, plan.cycleStart);
const dueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn);
const userNow = getUserMidnight(timezone, now);
if (cycleStart >= dueDate || cycleStart > userNow) {
cycleStart = userNow;
}
let totalPeriods = 1;
let elapsedPeriods = 1;
if (user?.firstIncomeDate && user?.incomeFrequency) {
totalPeriods = countPayPeriodsBetween(
cycleStart,
dueDate,
user.firstIncomeDate,
user.incomeFrequency,
timezone
);
elapsedPeriods = countPayPeriodsBetween(
cycleStart,
userNow,
user.firstIncomeDate,
user.incomeFrequency,
timezone
);
}
totalPeriods = Math.max(1, totalPeriods);
elapsedPeriods = Math.max(1, Math.min(elapsedPeriods, totalPeriods));
const targetFunded = Math.min(
totalAmount,
Math.ceil((totalAmount * elapsedPeriods) / totalPeriods)
);
const needed = Math.max(0, targetFunded - fundedAmount);
if (needed === 0) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
message: "No catch-up needed",
};
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const availableBudget = categories.reduce(
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
0
);
if (availableBudget < needed) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
availableBudget,
message: "Insufficient available budget",
};
}
const shareResult = computeWithdrawShares(categories, needed);
if (!shareResult.ok) {
return {
ok: true,
planId,
funded: false,
fundedAmountCents: 0,
fundedCents: fundedAmount,
totalCents: totalAmount,
availableBudget,
message:
shareResult.reason === "no_percent"
? "No category percentages available"
: "Insufficient category balances",
};
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { decrement: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(needed),
},
});
const newFunded = fundedAmount + needed;
await tx.fixedPlan.update({
where: { id: planId },
data: {
fundedCents: BigInt(newFunded),
currentFundedCents: BigInt(newFunded),
lastFundingDate: new Date(),
needsFundingThisPeriod: newFunded < totalAmount,
},
});
return {
ok: true,
planId,
funded: true,
fundedAmountCents: needed,
fundedCents: newFunded,
totalCents: totalAmount,
availableBudget,
message: "Catch-up funded from available budget",
};
});
});
// ----- Transactions: list -----
app.get("/transactions", async (req, reply) => {
const Query = z.object({
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
q: z.string().trim().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
bucketId: z.string().min(1).optional(),
categoryId: z.string().min(1).optional(),
sort: z.enum(["date", "amount", "kind", "bucket"]).optional(),
direction: z.enum(["asc", "desc"]).optional(),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply
.code(400)
.send({ message: "Invalid query", issues: parsed.error.issues });
}
const {
from,
to,
kind,
q,
bucketId: rawBucketId,
categoryId,
sort = "date",
direction = "desc",
page,
limit,
} = parsed.data;
const bucketId = rawBucketId ?? categoryId;
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const where: Record<string, unknown> = { userId };
if (from || to) {
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to);
}
if (kind) {
where.kind = kind;
} else {
where.kind = { in: ["variable_spend", "fixed_payment"] };
}
const flexibleOr: any[] = [];
if (typeof q === "string" && q.trim() !== "") {
const qTrim = q.trim();
const asCents = parseCurrencyToCents(qTrim);
if (asCents > 0) {
flexibleOr.push({ amountCents: toBig(asCents) });
}
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } });
flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } });
}
if (bucketId) {
if (!kind || kind === "variable_spend") {
flexibleOr.push({ categoryId: bucketId });
}
if (!kind || kind === "fixed_payment") {
flexibleOr.push({ planId: bucketId });
}
}
if (flexibleOr.length > 0) {
const existing = Array.isArray((where as any).OR) ? (where as any).OR : [];
(where as any).OR = [...existing, ...flexibleOr];
}
const skip = (page - 1) * limit;
const orderDirection = direction === "asc" ? "asc" : "desc";
const orderBy =
sort === "amount"
? [
{ amountCents: orderDirection as Prisma.SortOrder },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: sort === "kind"
? [
{ kind: orderDirection as Prisma.SortOrder },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: sort === "bucket"
? [
{ category: { name: orderDirection as Prisma.SortOrder } },
{ plan: { name: orderDirection as Prisma.SortOrder } },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: [{ occurredAt: orderDirection as Prisma.SortOrder }];
const txInclude = Prisma.validator<Prisma.TransactionInclude>()({
category: { select: { name: true } },
plan: { select: { name: true } },
});
type TxWithRelations = Prisma.TransactionGetPayload<{
include: typeof txInclude;
}>;
const [total, itemsRaw] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy,
skip,
take: limit,
include: txInclude,
}) as Promise<TxWithRelations[]>,
]);
const items = itemsRaw.map((tx) => ({
id: tx.id,
kind: tx.kind,
amountCents: tx.amountCents,
occurredAt: tx.occurredAt,
categoryId: tx.categoryId,
categoryName:
tx.category?.name ??
(tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null),
planId: tx.planId,
planName: tx.plan?.name ?? null,
note: tx.note ?? null,
receiptUrl: tx.receiptUrl ?? null,
isReconciled: !!tx.isReconciled,
isAutoPayment: !!tx.isAutoPayment,
}));
return { items, page, limit, total };
});
app.patch("/transactions/:id", mutationRateLimit, async (req, reply) => {
const Params = z.object({ id: z.string().min(1) });
const Body = z.object({
note: z
.string()
.trim()
.max(500)
.or(z.literal(""))
.optional(),
receiptUrl: z
.string()
.trim()
.max(2048)
.url()
.or(z.literal(""))
.optional(),
isReconciled: z.boolean().optional(),
});
const params = Params.safeParse(req.params);
const parsed = Body.safeParse(req.body);
if (!params.success || !parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const id = params.data.id;
if (
parsed.data.note === undefined &&
parsed.data.receiptUrl === undefined &&
parsed.data.isReconciled === undefined
) {
return reply.code(400).send({ message: "No fields to update" });
}
const existing = await app.prisma.transaction.findFirst({ where: { id, userId } });
if (!existing) return reply.code(404).send({ message: "Transaction not found" });
const data: Prisma.TransactionUpdateInput = {};
if (parsed.data.note !== undefined) {
const value = parsed.data.note.trim();
data.note = value.length > 0 ? value : null;
}
if (parsed.data.receiptUrl !== undefined) {
const url = parsed.data.receiptUrl.trim();
data.receiptUrl = url.length > 0 ? url : null;
}
if (parsed.data.isReconciled !== undefined) {
data.isReconciled = parsed.data.isReconciled;
}
const updated = await app.prisma.transaction.updateMany({
where: { id, userId },
data,
});
if (updated.count === 0) return reply.code(404).send({ message: "Transaction not found" });
const refreshed = await app.prisma.transaction.findFirst({
where: { id, userId },
select: {
id: true,
note: true,
receiptUrl: true,
isReconciled: true,
},
});
return refreshed;
});
app.delete("/transactions/:id", mutationRateLimit, async (req, reply) => {
const Params = z.object({ id: z.string().min(1) });
const params = Params.safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid transaction id" });
}
const userId = req.userId;
const id = params.data.id;
return await app.prisma.$transaction(async (tx) => {
const existing = await tx.transaction.findFirst({
where: { id, userId },
});
if (!existing) return reply.code(404).send({ message: "Transaction not found" });
const amountCents = Number(existing.amountCents ?? 0n);
if (existing.kind === "variable_spend") {
if (!existing.categoryId) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const shareResult = computeDepositShares(categories, amountCents);
if (!shareResult.ok) {
return reply.code(400).send({ message: "No category percentages available." });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { increment: BigInt(s.share) } },
});
}
} else {
const updated = await tx.variableCategory.updateMany({
where: { id: existing.categoryId, userId },
data: { balanceCents: { increment: BigInt(amountCents) } },
});
if (updated.count === 0) {
return reply.code(404).send({ message: "Category not found" });
}
}
} else if (existing.kind === "fixed_payment") {
if (!existing.planId) {
return reply.code(400).send({ message: "Transaction missing planId" });
}
const plan = await tx.fixedPlan.findFirst({
where: { id: existing.planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Fixed plan not found" });
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const shareResult = computeDepositShares(categories, amountCents);
if (!shareResult.ok) {
return reply.code(400).send({ message: "No category percentages available." });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { increment: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: existing.planId,
amountCents: BigInt(-amountCents),
incomeId: null,
},
});
const fundedBefore = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const newFunded = Math.max(0, fundedBefore - amountCents);
const updatedPlan = await tx.fixedPlan.updateMany({
where: { id: plan.id, userId },
data: {
fundedCents: BigInt(newFunded),
currentFundedCents: BigInt(newFunded),
needsFundingThisPeriod: newFunded < total,
},
});
if (updatedPlan.count === 0) {
return reply.code(404).send({ message: "Fixed plan not found" });
}
}
const deleted = await tx.transaction.deleteMany({ where: { id, userId } });
if (deleted.count === 0) return reply.code(404).send({ message: "Transaction not found" });
return { ok: true, id };
});
});
// ----- Variable categories -----
const CatBody = z.object({
name: z.string().trim().min(1),
percent: z.number().int().min(0).max(100),
isSavings: z.boolean(),
priority: z.number().int().min(0),
});
async function assertPercentTotal(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { percent: true, isSavings: true },
});
const sum = categories.reduce((total, cat) => total + (cat.percent ?? 0), 0);
const savingsSum = categories.reduce(
(total, cat) => total + (cat.isSavings ? cat.percent ?? 0 : 0),
0
);
// Allow partial states during onboarding (< 100%), but enforce exact 100% when sum would be >= 100%
if (sum > 100) {
const err: any = new Error("Percents must sum to 100");
err.statusCode = 400;
err.code = "PERCENT_TOTAL_OVER_100";
throw err;
}
if (sum >= 100 && savingsSum < 20) {
const err: any = new Error(
`Savings must total at least 20% (currently ${savingsSum}%)`
);
err.statusCode = 400;
err.code = "SAVINGS_MINIMUM";
throw err;
}
}
app.post("/variable-categories", mutationRateLimit, async (req, reply) => {
const parsed = CatBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const normalizedName = parsed.data.name.trim().toLowerCase();
return await app.prisma.$transaction(async (tx) => {
try {
const created = await tx.variableCategory.create({
data: { userId, balanceCents: 0n, ...parsed.data, name: normalizedName },
select: { id: true },
});
await assertPercentTotal(tx, userId);
return reply.status(201).send(created);
} catch (error: any) {
if (error.code === 'P2002') {
return reply.status(400).send({ error: 'DUPLICATE_NAME', message: `Category name '${parsed.data.name}' already exists` });
}
throw error;
}
});
});
app.patch("/variable-categories/:id", mutationRateLimit, async (req, reply) => {
const patch = CatBody.partial().safeParse(req.body);
if (!patch.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const id = String((req.params as any).id);
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const updateData = {
...patch.data,
...(patch.data.name !== undefined ? { name: patch.data.name.trim().toLowerCase() } : {}),
};
return await app.prisma.$transaction(async (tx) => {
const exists = await tx.variableCategory.findFirst({
where: { id, userId },
});
if (!exists) return reply.code(404).send({ message: "Not found" });
const updated = await tx.variableCategory.updateMany({
where: { id, userId },
data: updateData,
});
if (updated.count === 0) return reply.code(404).send({ message: "Not found" });
await assertPercentTotal(tx, userId);
return { ok: true };
});
});
app.delete("/variable-categories/:id", mutationRateLimit, async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const exists = await app.prisma.variableCategory.findFirst({
where: { id, userId },
});
if (!exists) return reply.code(404).send({ message: "Not found" });
const deleted = await app.prisma.variableCategory.deleteMany({
where: { id, userId },
});
if (deleted.count === 0) return reply.code(404).send({ message: "Not found" });
await assertPercentTotal(app.prisma, userId);
return { ok: true };
});
app.post("/variable-categories/rebalance", mutationRateLimit, async (req, reply) => {
const userId = req.userId;
const categories = await app.prisma.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
if (categories.length === 0) {
return { ok: true, applied: false };
}
const hasNegative = categories.some(
(c) => Number(c.balanceCents ?? 0n) < 0
);
if (hasNegative) {
return reply.code(400).send({
ok: false,
code: "NEGATIVE_BALANCE",
message: "Cannot rebalance while a category has a negative balance.",
});
}
const totalBalance = categories.reduce(
(sum, c) => sum + Number(c.balanceCents ?? 0n),
0
);
if (totalBalance <= 0) {
return { ok: true, applied: false };
}
const shareResult = computeDepositShares(categories, totalBalance);
if (!shareResult.ok) {
return reply.code(400).send({
ok: false,
code: "NO_PERCENT",
message: "No percent totals available to rebalance.",
});
}
await app.prisma.$transaction(
shareResult.shares.map((s) =>
app.prisma.variableCategory.update({
where: { id: s.id },
data: { balanceCents: BigInt(s.share) },
})
)
);
return { ok: true, applied: true, totalBalance };
});
// ----- Fixed plans -----
const PlanBody = z.object({
name: z.string().trim().min(1),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).optional(),
priority: z.number().int().min(0),
dueOn: z.string().datetime(),
cycleStart: z.string().datetime().optional(),
frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(),
autoPayEnabled: z.boolean().optional(),
paymentSchedule: z
.object({
frequency: z.enum(["daily", "weekly", "biweekly", "monthly", "custom"]),
dayOfMonth: z.number().int().min(1).max(31).optional(),
dayOfWeek: z.number().int().min(0).max(6).optional(),
everyNDays: z.number().int().min(1).max(365).optional(),
minFundingPercent: z.number().min(0).max(100).default(100),
})
.partial({ dayOfMonth: true, dayOfWeek: true, everyNDays: true })
.optional(),
nextPaymentDate: z.string().datetime().optional(),
maxRetryAttempts: z.number().int().min(0).max(10).optional(),
});
app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
const parsed = PlanBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const totalBig = toBig(parsed.data.totalCents);
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
if (fundedBig > totalBig) {
return reply
.code(400)
.send({ message: "fundedCents cannot exceed totalCents" });
}
const autoPayEnabled = !!parsed.data.autoPayEnabled && !!parsed.data.paymentSchedule;
const paymentSchedule = parsed.data.paymentSchedule
? { ...parsed.data.paymentSchedule, minFundingPercent: parsed.data.paymentSchedule.minFundingPercent ?? 100 }
: null;
const nextPaymentDate =
parsed.data.nextPaymentDate && autoPayEnabled
? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.nextPaymentDate))
: autoPayEnabled && parsed.data.dueOn
? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn))
: null;
// Extract frequency from explicit field or paymentSchedule
let frequency = parsed.data.frequency;
if (!frequency && paymentSchedule?.frequency) {
// Map paymentSchedule frequency to plan frequency
const scheduleFreq = paymentSchedule.frequency;
if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") {
frequency = scheduleFreq;
}
}
try {
const created = await app.prisma.fixedPlan.create({
data: {
userId,
name: parsed.data.name,
totalCents: totalBig,
fundedCents: fundedBig,
currentFundedCents: fundedBig,
priority: parsed.data.priority,
dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)),
cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.cycleStart ?? parsed.data.dueOn)),
frequency: frequency || null,
fundingMode: "auto-on-deposit",
autoPayEnabled,
paymentSchedule: paymentSchedule ?? Prisma.DbNull,
nextPaymentDate: autoPayEnabled ? nextPaymentDate : null,
maxRetryAttempts: parsed.data.maxRetryAttempts ?? 3,
lastFundingDate: fundedBig > 0n ? new Date() : null,
},
select: { id: true },
});
return reply.code(201).send(created);
} catch (error: any) {
if (error.code === 'P2002') {
return reply.code(400).send({ error: 'DUPLICATE_NAME', message: `Fixed plan name '${parsed.data.name}' already exists` });
}
throw error;
}
});
app.patch("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
const patch = PlanBody.partial().safeParse(req.body);
if (!patch.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const id = String((req.params as any).id);
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const plan = await app.prisma.fixedPlan.findFirst({
where: { id, userId },
});
if (!plan) return reply.code(404).send({ message: "Not found" });
const total =
"totalCents" in patch.data
? toBig(patch.data.totalCents as number)
: plan.totalCents ?? 0n;
const funded =
"fundedCents" in patch.data
? toBig(patch.data.fundedCents as number)
: plan.fundedCents ?? 0n;
if (funded > total) {
return reply
.code(400)
.send({ message: "fundedCents cannot exceed totalCents" });
}
const hasScheduleInPatch = "paymentSchedule" in patch.data;
const paymentSchedule =
hasScheduleInPatch && patch.data.paymentSchedule
? { ...patch.data.paymentSchedule, minFundingPercent: patch.data.paymentSchedule.minFundingPercent ?? 100 }
: hasScheduleInPatch
? null
: undefined;
const autoPayEnabled =
"autoPayEnabled" in patch.data
? !!patch.data.autoPayEnabled && paymentSchedule !== null && (paymentSchedule !== undefined ? true : !!plan.paymentSchedule)
: paymentSchedule === null
? false
: plan.autoPayEnabled;
const nextPaymentDate =
"nextPaymentDate" in patch.data
? patch.data.nextPaymentDate
? getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.nextPaymentDate))
: null
: undefined;
const updated = await app.prisma.fixedPlan.updateMany({
where: { id, userId },
data: {
...patch.data,
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
...(patch.data.fundedCents !== undefined
? { fundedCents: funded, currentFundedCents: funded, lastFundingDate: new Date() }
: {}),
...(patch.data.dueOn ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : {}),
...(patch.data.cycleStart
? { cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.cycleStart)) }
: {}),
...(paymentSchedule !== undefined
? { paymentSchedule: paymentSchedule ?? Prisma.DbNull }
: {}),
...(autoPayEnabled !== undefined ? { autoPayEnabled } : {}),
...(nextPaymentDate !== undefined
? { nextPaymentDate: autoPayEnabled ? nextPaymentDate : null }
: {}),
...(patch.data.maxRetryAttempts !== undefined
? { maxRetryAttempts: patch.data.maxRetryAttempts }
: {}),
},
});
if (updated.count === 0) return reply.code(404).send({ message: "Not found" });
return { ok: true };
});
app.delete("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
const id = String((req.params as any).id);
const userId = req.userId;
const plan = await app.prisma.fixedPlan.findFirst({
where: { id, userId },
select: { id: true, fundedCents: true, currentFundedCents: true },
});
if (!plan) return reply.code(404).send({ message: "Not found" });
return await app.prisma.$transaction(async (tx) => {
const refundCents = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
if (refundCents > 0) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const shareResult = computeDepositShares(categories, refundCents);
if (shareResult.ok) {
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { increment: BigInt(s.share) } },
});
}
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: plan.id,
amountCents: BigInt(-refundCents),
incomeId: null,
},
});
}
await tx.fixedPlan.deleteMany({ where: { id, userId } });
return { ok: true, refundedCents: refundCents };
});
});
// ----- Fixed plans: due list -----
app.get("/fixed-plans/due", async (req, reply) => {
const Query = z.object({
asOf: z.string().datetime().optional(),
daysAhead: z.coerce.number().int().min(0).max(60).default(0),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid query" });
}
const userId = req.userId;
const now = new Date();
const asOfDate = parsed.data.asOf ? new Date(parsed.data.asOf) : now;
const user = await app.prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const userTimezone = user?.timezone || "America/New_York";
const todayUser = getUserMidnight(userTimezone, asOfDate);
const cutoff = new Date(todayUser.getTime() + parsed.data.daysAhead * DAY_MS);
const plans = await app.prisma.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
priority: true,
},
});
const items = plans
.map((p) => {
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n);
const total = Number(p.totalCents ?? 0n);
const remaining = Math.max(0, total - funded);
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
const dueDate = new Date(p.dueOn);
const dueUser = getUserMidnightFromDateOnly(userTimezone, dueDate);
return {
id: p.id,
name: p.name,
dueOn: dueUser.toISOString(),
remainingCents: remaining,
percentFunded,
isDue: dueUser.getTime() <= todayUser.getTime(),
isOverdue: dueUser.getTime() < todayUser.getTime(),
};
})
// Include all items due by cutoff, even if fully funded (remaining 0).
.filter((p) => {
const dueDate = new Date(p.dueOn);
return (
getUserMidnightFromDateOnly(userTimezone, dueDate).getTime() <=
cutoff.getTime()
);
});
return { items, asOfISO: cutoff.toISOString() };
});
// ----- Fixed plans: pay now wrapper -----
app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
const Params = z.object({ id: z.string().min(1) });
const Body = z.object({
occurredAtISO: z.string().datetime().optional(),
overrideDueOnISO: z.string().datetime().optional(),
fundingSource: z.enum(["funded", "savings", "deficit"]).optional(),
savingsCategoryId: z.string().optional(),
note: z.string().trim().max(500).optional(),
});
const params = Params.safeParse(req.params);
const parsed = Body.safeParse(req.body);
if (!params.success || !parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const id = params.data.id;
const { occurredAtISO, overrideDueOnISO, fundingSource, savingsCategoryId, note } = parsed.data;
return await app.prisma.$transaction(async (tx) => {
const plan = await tx.fixedPlan.findFirst({
where: { id, userId },
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
frequency: true,
autoPayEnabled: true,
nextPaymentDate: true,
paymentSchedule: true,
},
});
if (!plan) {
const err: any = new Error("Plan not found");
err.statusCode = 404;
throw err;
}
const total = Number(plan.totalCents ?? 0n);
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const shortage = Math.max(0, total - funded);
const isOneTime = !plan.frequency || plan.frequency === "one-time";
let savingsUsed = false;
let deficitCovered = false;
// Decide funding source automatically if fully funded
const source = funded >= total ? (fundingSource ?? "funded") : fundingSource;
if (shortage > 0) {
if (!source) {
const err: any = new Error("Insufficient funds: specify fundingSource (savings or deficit)");
err.statusCode = 400;
err.code = "INSUFFICIENT_FUNDS";
throw err;
}
if (source === "savings") {
if (!savingsCategoryId) {
const err: any = new Error("savingsCategoryId required when fundingSource is savings");
err.statusCode = 400;
err.code = "SAVINGS_CATEGORY_REQUIRED";
throw err;
}
const cat = await tx.variableCategory.findFirst({
where: { id: savingsCategoryId, userId },
select: { id: true, name: true, isSavings: true, balanceCents: true },
});
if (!cat) {
const err: any = new Error("Savings category not found");
err.statusCode = 404;
err.code = "SAVINGS_NOT_FOUND";
throw err;
}
if (!cat.isSavings) {
const err: any = new Error("Selected category is not savings");
err.statusCode = 400;
err.code = "NOT_SAVINGS_CATEGORY";
throw err;
}
const bal = Number(cat.balanceCents ?? 0n);
if (shortage > bal) {
const err: any = new Error("Savings balance insufficient to cover shortage");
err.statusCode = 400;
err.code = "OVERDRAFT_SAVINGS";
throw err;
}
// Deduct from savings balance
await tx.variableCategory.update({
where: { id: cat.id },
data: { balanceCents: toBig(bal - shortage) },
});
// Record a variable_spend transaction to reflect covering shortage
await tx.transaction.create({
data: {
userId,
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
kind: "variable_spend",
amountCents: toBig(shortage),
categoryId: cat.id,
planId: null,
note: `Covered shortage for ${plan.name}`,
receiptUrl: null,
isReconciled: false,
},
});
savingsUsed = true;
} else if (source === "deficit") {
// Allow proceeding without additional funding. Tracking of deficit can be expanded later.
deficitCovered = true;
}
}
// Fetch user to check incomeType for conditional logic
const user = await tx.user.findUnique({
where: { id: userId },
select: { incomeType: true, timezone: true },
});
if (!user) {
const err: any = new Error("User not found");
err.statusCode = 404;
throw err;
}
const userTimezone = user.timezone ?? "America/New_York";
// Update plan: reset funded to 0 and set new due date
const updateData: any = {
fundedCents: 0n,
currentFundedCents: 0n,
};
// For REGULAR users with payment plans, resume funding after payment
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
if (user.incomeType === "regular" && hasPaymentSchedule) {
updateData.needsFundingThisPeriod = true;
}
let nextDue = plan.dueOn;
if (overrideDueOnISO) {
nextDue = getUserMidnightFromDateOnly(userTimezone, new Date(overrideDueOnISO));
updateData.dueOn = nextDue;
} else {
// Try plan.frequency first, fallback to paymentSchedule.frequency
let frequency = plan.frequency;
if (!frequency && plan.paymentSchedule) {
const schedule = plan.paymentSchedule as any;
frequency = schedule.frequency;
}
if (frequency && frequency !== "one-time") {
nextDue = calculateNextDueDate(plan.dueOn, frequency as any, userTimezone);
updateData.dueOn = nextDue;
}
}
if (plan.autoPayEnabled) {
updateData.nextPaymentDate = nextDue;
}
const updatedPlan = isOneTime
? null
: await tx.fixedPlan.update({
where: { id: plan.id },
data: updateData,
select: { id: true, dueOn: true },
});
// Create the fixed payment transaction for full bill amount
const paymentTx = await tx.transaction.create({
data: {
userId,
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
kind: "fixed_payment",
amountCents: toBig(total),
categoryId: null,
planId: plan.id,
note: note?.trim() ? note.trim() : null,
receiptUrl: null,
isReconciled: false,
},
select: { id: true, occurredAt: true },
});
if (isOneTime) {
await tx.fixedPlan.deleteMany({ where: { id: plan.id, userId } });
}
return {
ok: true,
planId: plan.id,
transactionId: paymentTx.id,
nextDueOn: updatedPlan?.dueOn?.toISOString() ?? null,
savingsUsed,
deficitCovered,
shortageCents: shortage,
};
});
});
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,
};
});
});
const UserConfigBody = z.object({
incomeType: z.enum(["regular", "irregular"]).optional(),
totalBudgetCents: z.number().int().nonnegative().optional(),
budgetPeriod: z.enum(["weekly", "biweekly", "monthly"]).optional(),
incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]).optional(),
firstIncomeDate: z
.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)])
.nullable()
.optional(),
timezone: z.string().refine((value) => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return true;
} catch {
return false;
}
}, "Invalid timezone").optional(), // IANA timezone identifier
fixedExpensePercentage: z.number().int().min(0).max(100).optional(),
});
app.patch("/user/config", async (req, reply) => {
const parsed = UserConfigBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid user config data" });
}
const userId = req.userId;
const updateData: any = {};
const scheduleChange =
parsed.data.incomeFrequency !== undefined ||
parsed.data.firstIncomeDate !== undefined;
const wantsFirstIncomeDate = parsed.data.firstIncomeDate !== undefined;
if (parsed.data.incomeFrequency) updateData.incomeFrequency = parsed.data.incomeFrequency;
if (parsed.data.totalBudgetCents !== undefined) updateData.totalBudgetCents = BigInt(parsed.data.totalBudgetCents);
if (parsed.data.budgetPeriod) updateData.budgetPeriod = parsed.data.budgetPeriod;
if (parsed.data.incomeType) updateData.incomeType = parsed.data.incomeType;
if (parsed.data.timezone) updateData.timezone = parsed.data.timezone;
if (parsed.data.fixedExpensePercentage !== undefined) {
updateData.fixedExpensePercentage = parsed.data.fixedExpensePercentage;
}
const updated = await app.prisma.$transaction(async (tx) => {
const existing = await tx.user.findUnique({
where: { id: userId },
select: { incomeType: true, timezone: true },
});
const effectiveTimezone = parsed.data.timezone ?? existing?.timezone ?? "America/New_York";
if (wantsFirstIncomeDate) {
updateData.firstIncomeDate = parsed.data.firstIncomeDate
? getUserMidnightFromDateOnly(effectiveTimezone, new Date(parsed.data.firstIncomeDate))
: null;
}
const updatedUser = await tx.user.update({
where: { id: userId },
data: updateData,
select: {
incomeFrequency: true,
incomeType: true,
totalBudgetCents: true,
budgetPeriod: true,
firstIncomeDate: true,
timezone: true,
fixedExpensePercentage: true,
},
});
const finalIncomeType = updateData.incomeType ?? existing?.incomeType ?? "regular";
if (scheduleChange && finalIncomeType === "regular") {
await tx.fixedPlan.updateMany({
where: { userId, paymentSchedule: { not: Prisma.DbNull } },
data: { needsFundingThisPeriod: true },
});
}
return updatedUser;
});
return {
incomeFrequency: updated.incomeFrequency,
incomeType: updated.incomeType || "regular",
totalBudgetCents: updated.totalBudgetCents ? Number(updated.totalBudgetCents) : null,
budgetPeriod: updated.budgetPeriod,
firstIncomeDate: updated.firstIncomeDate
? getUserMidnightFromDateOnly(updated.timezone ?? "America/New_York", updated.firstIncomeDate).toISOString()
: null,
timezone: updated.timezone,
fixedExpensePercentage: updated.fixedExpensePercentage ?? 40,
};
});
return app;
}
const PORT = env.PORT;
const HOST = process.env.HOST || "0.0.0.0";
const app = await buildApp();
export default app;
if (process.env.NODE_ENV !== "test") {
app.listen({ port: PORT, host: HOST }).catch((err) => {
app.log.error(err);
process.exit(1);
});
}