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 = 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;