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