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; } interface FastifyRequest { userId: string; } } export async function buildApp(overrides: Partial = {}): Promise { 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) => { 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(); incomeEvents.forEach((evt) => { const key = monthKey(evt.postedAt); incomeByMonth.set(key, (incomeByMonth.get(key) ?? 0) + Number(evt.amountCents ?? 0n)); }); const spendByMonth = new Map(); 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 = { 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()({ 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, ]); 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); }); }