Files
SkyMoney/api/src/routes/fixed-plans.ts
Ricearoni1245 9c7f4d5139
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
removed unneccesary files
2026-03-21 17:30:11 -05:00

1316 lines
43 KiB
TypeScript

import type { FastifyPluginAsync } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
countPayPeriodsBetween,
getUserMidnight,
getUserMidnightFromDateOnly,
} from "../allocator.js";
import { getUserTimezone } from "../services/user-context.js";
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
type ShareResult =
| { ok: true; shares: Array<{ id: string; share: number }> }
| { ok: false; reason: string };
type FixedPlansRoutesOptions = {
mutationRateLimit: RateLimitRouteOptions;
computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
calculateNextDueDate: (currentDueDate: Date, frequency: string, timezone?: string) => Date;
toBig: (n: number | string | bigint) => bigint;
};
const DAY_MS = 24 * 60 * 60 * 1000;
const PlanBody = z.object({
name: z.string().trim().min(1),
totalCents: z.number().int().min(0),
fundedCents: z.number().int().min(0).optional(),
amountMode: z.enum(["fixed", "estimated"]).optional(),
estimatedCents: 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(),
});
const PlanAmountMode = z.enum(["fixed", "estimated"]);
const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
app,
opts
) => {
app.patch("/fixed-plans/:id/early-funding", opts.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 getUserTimezone(app.prisma, userId);
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 = opts.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,
});
});
app.post("/fixed-plans/:id/attempt-final-funding", opts.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;
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
);
if (availableBudget >= remainingNeeded) {
const shareResult = opts.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 = opts.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 {
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.`,
};
}
});
});
app.patch("/fixed-plans/:id/mark-unpaid", opts.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,
},
});
return reply.send({
ok: true,
planId,
isOverdue: true,
overdueAmount: Math.max(0, remainingBalance),
});
});
app.post("/fixed-plans/:id/fund-from-available", opts.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 = opts.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",
};
});
});
app.post("/fixed-plans/:id/catch-up-funding", opts.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 = opts.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",
};
});
});
app.post("/fixed-plans", opts.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 getUserTimezone(app.prisma, userId);
const amountMode = parsed.data.amountMode ?? "fixed";
if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) {
return reply.code(400).send({
code: "ESTIMATED_CENTS_REQUIRED",
message: "estimatedCents is required when amountMode is estimated",
});
}
const computedTotalCents =
amountMode === "estimated"
? parsed.data.totalCents ?? parsed.data.estimatedCents ?? 0
: parsed.data.totalCents;
const totalBig = opts.toBig(computedTotalCents);
const fundedBig = opts.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;
let frequency = parsed.data.frequency;
if (!frequency && paymentSchedule?.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,
amountMode,
estimatedCents:
amountMode === "estimated" && parsed.data.estimatedCents !== undefined
? opts.toBig(parsed.data.estimatedCents)
: null,
actualCents: null,
actualCycleDueOn: null,
actualRecordedAt: null,
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", opts.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 getUserTimezone(app.prisma, userId);
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
? opts.toBig(patch.data.totalCents as number)
: plan.totalCents ?? 0n;
const funded =
"fundedCents" in patch.data
? opts.toBig(patch.data.fundedCents as number)
: plan.fundedCents ?? 0n;
if (funded > total) {
return reply
.code(400)
.send({ message: "fundedCents cannot exceed totalCents" });
}
const amountMode =
patch.data.amountMode !== undefined
? PlanAmountMode.parse(patch.data.amountMode)
: ((plan.amountMode as "fixed" | "estimated" | null) ?? "fixed");
if (
amountMode === "estimated" &&
patch.data.estimatedCents === undefined &&
plan.estimatedCents === null
) {
return reply.code(400).send({
code: "ESTIMATED_CENTS_REQUIRED",
message: "estimatedCents is required when amountMode is estimated",
});
}
const nextEstimatedCents =
patch.data.estimatedCents !== undefined
? opts.toBig(patch.data.estimatedCents)
: plan.estimatedCents;
const hasActualForCycle =
plan.actualCycleDueOn && plan.actualCycleDueOn.getTime() === plan.dueOn.getTime();
const updateTotalFromEstimate =
amountMode === "estimated" &&
patch.data.estimatedCents !== undefined &&
!hasActualForCycle &&
patch.data.totalCents === undefined;
const nextTotal =
updateTotalFromEstimate
? opts.toBig(patch.data.estimatedCents as number)
: total;
if (funded > nextTotal) {
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,
amountMode,
estimatedCents:
amountMode === "estimated"
? (nextEstimatedCents ?? null)
: null,
...(patch.data.totalCents !== undefined || updateTotalFromEstimate ? { totalCents: nextTotal } : {}),
...(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", opts.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 = opts.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 };
});
});
app.post("/fixed-plans/:id/true-up-actual", opts.mutationRateLimit, async (req, reply) => {
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
const parsed = z.object({ actualCents: z.number().int().min(0) }).safeParse(req.body);
if (!params.success || !parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const planId = params.data.id;
const userId = req.userId;
const actualCents = parsed.data.actualCents;
return await app.prisma.$transaction(async (tx) => {
const plan = await tx.fixedPlan.findFirst({
where: { id: planId, userId },
select: {
id: true,
amountMode: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
},
});
if (!plan) {
const err: any = new Error("Plan not found");
err.statusCode = 404;
throw err;
}
if ((plan.amountMode ?? "fixed") !== "estimated") {
const err: any = new Error("True-up is only available for estimated plans");
err.statusCode = 400;
err.code = "PLAN_NOT_ESTIMATED";
throw err;
}
const previousTargetCents = Number(plan.totalCents ?? 0n);
const deltaCents = actualCents - previousTargetCents;
const currentFundedCents = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
let autoPulledCents = 0;
let refundedCents = 0;
let nextFundedCents = currentFundedCents;
if (deltaCents > 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
);
const desiredPull = Math.min(deltaCents, Math.max(0, availableBudget));
if (desiredPull > 0) {
const shareResult = opts.computeWithdrawShares(categories, desiredPull);
if (shareResult.ok) {
autoPulledCents = shareResult.shares.reduce((sum, share) => sum + share.share, 0);
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) } },
});
}
if (autoPulledCents > 0) {
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(autoPulledCents),
incomeId: null,
},
});
}
nextFundedCents += autoPulledCents;
}
}
} else if (deltaCents < 0) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const excessFunded = Math.max(0, currentFundedCents - actualCents);
if (excessFunded > 0) {
const shareResult = opts.computeDepositShares(categories, excessFunded);
if (shareResult.ok) {
refundedCents = shareResult.shares.reduce((sum, share) => sum + share.share, 0);
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) } },
});
}
if (refundedCents > 0) {
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(-refundedCents),
incomeId: null,
},
});
}
nextFundedCents = Math.max(0, currentFundedCents - refundedCents);
}
}
}
const now = new Date();
await tx.fixedPlan.update({
where: { id: planId },
data: {
totalCents: BigInt(actualCents),
fundedCents: BigInt(nextFundedCents),
currentFundedCents: BigInt(nextFundedCents),
actualCents: BigInt(actualCents),
actualCycleDueOn: plan.dueOn,
actualRecordedAt: now,
},
});
const remainingShortfallCents = Math.max(0, actualCents - nextFundedCents);
return {
ok: true,
planId,
amountMode: "estimated" as const,
previousTargetCents,
actualCents,
deltaCents,
autoPulledCents,
refundedCents,
remainingShortfallCents,
fundedCents: nextFundedCents,
totalCents: actualCents,
};
});
});
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(),
};
})
.filter((p) => {
const dueDate = new Date(p.dueOn);
return (
getUserMidnightFromDateOnly(userTimezone, dueDate).getTime() <=
cutoff.getTime()
);
});
return { items, asOfISO: cutoff.toISOString() };
});
app.post("/fixed-plans/:id/pay-now", opts.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,
amountMode: true,
estimatedCents: 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;
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;
}
await tx.variableCategory.update({
where: { id: cat.id },
data: { balanceCents: opts.toBig(bal - shortage) },
});
await tx.transaction.create({
data: {
userId,
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
kind: "variable_spend",
amountCents: opts.toBig(shortage),
categoryId: cat.id,
planId: null,
note: `Covered shortage for ${plan.name}`,
receiptUrl: null,
isReconciled: false,
},
});
savingsUsed = true;
} else if (source === "deficit") {
deficitCovered = true;
}
}
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";
const updateData: any = {
fundedCents: 0n,
currentFundedCents: 0n,
};
const isEstimatedPlan = plan.amountMode === "estimated";
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 {
let frequency = plan.frequency;
if (!frequency && plan.paymentSchedule) {
const schedule = plan.paymentSchedule as any;
frequency = schedule.frequency;
}
if (frequency && frequency !== "one-time") {
nextDue = opts.calculateNextDueDate(plan.dueOn, frequency as any, userTimezone);
updateData.dueOn = nextDue;
}
}
if (isEstimatedPlan) {
const estimate = Number(plan.estimatedCents ?? 0n);
updateData.totalCents = BigInt(Math.max(0, estimate));
updateData.actualCents = null;
updateData.actualCycleDueOn = null;
updateData.actualRecordedAt = null;
}
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 },
});
const paymentTx = await tx.transaction.create({
data: {
userId,
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
kind: "fixed_payment",
amountCents: opts.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,
};
});
});
};
export default fixedPlansRoutes;