From 020d55a77ea54807cd35f4591038babeacd2262b Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Tue, 17 Mar 2026 20:28:08 -0500 Subject: [PATCH] phase 5: fixed expense logic simplified and compacted --- api/src/routes/fixed-plans.ts | 1422 ++++++++++++++++++++++--- api/src/server.ts | 1300 +--------------------- api/tests/payment-rollover.test.ts | 137 ++- api/tests/transactions.test.ts | 83 +- docs/api-phase5-move-log.md | 121 +++ docs/api-refactor-lightweight-plan.md | 124 ++- 6 files changed, 1675 insertions(+), 1512 deletions(-) create mode 100644 docs/api-phase5-move-log.md diff --git a/api/src/routes/fixed-plans.ts b/api/src/routes/fixed-plans.ts index 589c95a..d115ce6 100644 --- a/api/src/routes/fixed-plans.ts +++ b/api/src/routes/fixed-plans.ts @@ -1,186 +1,1320 @@ -import { FastifyPluginAsync } from "fastify"; +import type { FastifyPluginAsync } from "fastify"; +import { Prisma } from "@prisma/client"; import { z } from "zod"; -import { prisma } from "../prisma.js"; -import { getUserMidnight, getUserMidnightFromDateOnly } from "../allocator.js"; -import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { + countPayPeriodsBetween, + getUserMidnight, + getUserMidnightFromDateOnly, +} from "../allocator.js"; -const PaymentSchedule = z.object({ - frequency: z.enum(["monthly", "weekly", "biweekly", "daily"]), - dayOfMonth: z.number().int().min(1).max(31).optional(), - dayOfWeek: z.number().int().min(0).max(6).optional(), - minFundingPercent: z.number().min(0).max(100).default(100), -}); +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; -const NewPlan = z.object({ - name: z.string().min(1).max(120), +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).default(0), - priority: z.number().int().min(0).max(10_000), - dueOn: z.string().datetime(), // ISO + 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().default(false), - paymentSchedule: PaymentSchedule.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 PatchPlan = NewPlan.partial(); -const IdParam = z.object({ id: z.string().min(1) }); -const bi = (n: number | bigint | undefined) => BigInt(n ?? 0); -function validateFunding(total: bigint, funded: bigint) { - if (funded > total) { - const err: any = new Error("fundedCents must be ≤ totalCents"); - err.statusCode = 400; - err.code = "FUNDED_GT_TOTAL"; - throw err; - } -} +const PlanAmountMode = z.enum(["fixed", "estimated"]); -function calculateNextPaymentDate(dueDate: Date, schedule: any, timezone: string): Date { - const base = getUserMidnightFromDateOnly(timezone, dueDate); - const next = toZonedTime(base, timezone); - - switch (schedule.frequency) { - case "daily": - next.setUTCDate(next.getUTCDate() + 1); - break; - case "weekly": { - const targetDay = schedule.dayOfWeek ?? 0; - const currentDay = next.getUTCDay(); - const daysUntilTarget = (targetDay - currentDay + 7) % 7; - next.setUTCDate(next.getUTCDate() + (daysUntilTarget || 7)); - break; - } - case "monthly": { - const targetDay = schedule.dayOfMonth ?? next.getUTCDate(); - const nextMonth = next.getUTCMonth() + 1; - const nextYear = next.getUTCFullYear() + Math.floor(nextMonth / 12); - const nextMonthIndex = nextMonth % 12; - const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); - next.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); - break; - } - default: - next.setUTCDate(next.getUTCDate() + 30); // fallback - } - - next.setUTCHours(0, 0, 0, 0); - return fromZonedTime(next, timezone); -} - -const plugin: FastifyPluginAsync = async (app) => { - // CREATE - app.post("/fixed-plans", async (req, reply) => { +const fixedPlansRoutes: FastifyPluginAsync = async ( + app, + opts +) => { + app.patch("/fixed-plans/:id/early-funding", opts.mutationRateLimit, async (req, reply) => { const userId = req.userId; - const parsed = NewPlan.safeParse(req.body); - if (!parsed.success) return reply.status(400).send({ error: "INVALID_BODY", details: parsed.error.flatten() }); + 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 prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? "America/New_York"; - const totalBI = bi(parsed.data.totalCents); - const fundedBI = bi(parsed.data.fundedCents); - validateFunding(totalBI, fundedBI); + 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, + }, + }); - // Calculate next payment date if auto-pay is enabled - const nextPaymentDate = parsed.data.autoPayEnabled && parsed.data.paymentSchedule - ? calculateNextPaymentDate(new Date(parsed.data.dueOn), parsed.data.paymentSchedule, userTimezone) + 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 app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + "America/New_York"; + + 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; - // Extract frequency from explicit field or paymentSchedule let frequency = parsed.data.frequency; - if (!frequency && parsed.data.paymentSchedule?.frequency) { - const scheduleFreq = parsed.data.paymentSchedule.frequency; + if (!frequency && paymentSchedule?.frequency) { + const scheduleFreq = paymentSchedule.frequency; if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") { frequency = scheduleFreq; } } - const rec = await prisma.fixedPlan.create({ - data: { - userId, - name: parsed.data.name, - priority: parsed.data.priority, - dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)), - frequency: frequency || null, - totalCents: totalBI, - fundedCents: fundedBI, - currentFundedCents: fundedBI, - cycleStart: getUserMidnight(userTimezone, new Date()), // required by your schema - autoPayEnabled: parsed.data.autoPayEnabled ?? false, - paymentSchedule: parsed.data.paymentSchedule || undefined, - nextPaymentDate, - lastFundingDate: fundedBI > 0 ? new Date() : null, - }, - select: { id: true }, - }); - return reply.status(201).send(rec); + 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; + } }); - // UPDATE - app.patch("/fixed-plans/:id", async (req, reply) => { + 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 pid = IdParam.safeParse(req.params); - const patch = PatchPlan.safeParse(req.body); - if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); - if (!patch.success) return reply.status(400).send({ error: "INVALID_BODY", details: patch.error.flatten() }); const userTimezone = - (await prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? + (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? "America/New_York"; - const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); - if (!existing) return reply.status(404).send({ error: "NOT_FOUND" }); + const plan = await app.prisma.fixedPlan.findFirst({ + where: { id, userId }, + }); + if (!plan) return reply.code(404).send({ message: "Not found" }); - const nextTotal = patch.data.totalCents !== undefined ? bi(patch.data.totalCents) : (existing.totalCents as bigint); - const nextFunded = patch.data.fundedCents !== undefined ? bi(patch.data.fundedCents) : (existing.fundedCents as bigint); - validateFunding(nextTotal, nextFunded); + 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" }); + } - // Calculate next payment date if auto-pay settings changed - const nextPaymentDate = (patch.data.autoPayEnabled !== undefined || patch.data.paymentSchedule !== undefined) - ? ((patch.data.autoPayEnabled ?? existing.autoPayEnabled) && (patch.data.paymentSchedule ?? existing.paymentSchedule)) - ? calculateNextPaymentDate( - patch.data.dueOn ? new Date(patch.data.dueOn) : existing.dueOn, - patch.data.paymentSchedule ?? existing.paymentSchedule, - userTimezone - ) - : null - : undefined; + 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 prisma.fixedPlan.updateMany({ - where: { id: pid.data.id, userId }, + const updated = await app.prisma.fixedPlan.updateMany({ + where: { id, userId }, data: { - ...(patch.data.name !== undefined ? { name: patch.data.name } : null), - ...(patch.data.priority !== undefined ? { priority: patch.data.priority } : null), - ...(patch.data.dueOn !== undefined ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : null), - ...(patch.data.totalCents !== undefined ? { totalCents: bi(patch.data.totalCents) } : null), + ...patch.data, + amountMode, + estimatedCents: + amountMode === "estimated" + ? (nextEstimatedCents ?? null) + : null, + ...(patch.data.totalCents !== undefined || updateTotalFromEstimate ? { totalCents: nextTotal } : {}), ...(patch.data.fundedCents !== undefined - ? { - fundedCents: bi(patch.data.fundedCents), - currentFundedCents: bi(patch.data.fundedCents), - lastFundingDate: new Date(), - } - : null), - ...(patch.data.autoPayEnabled !== undefined ? { autoPayEnabled: patch.data.autoPayEnabled } : null), - ...(patch.data.paymentSchedule !== undefined ? { paymentSchedule: patch.data.paymentSchedule } : null), - ...(nextPaymentDate !== undefined ? { nextPaymentDate } : null), + ? { 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.status(404).send({ error: "NOT_FOUND" }); - - return reply.send({ ok: true }); + if (updated.count === 0) return reply.code(404).send({ message: "Not found" }); + return { ok: true }; }); - // DELETE - app.delete("/fixed-plans/:id", async (req, reply) => { + app.delete("/fixed-plans/:id", opts.mutationRateLimit, async (req, reply) => { + const id = String((req.params as any).id); const userId = req.userId; - const pid = IdParam.safeParse(req.params); - if (!pid.success) return reply.status(400).send({ error: "INVALID_ID", details: pid.error.flatten() }); + 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" }); - const existing = await prisma.fixedPlan.findFirst({ where: { id: pid.data.id, userId } }); - if (!existing) return reply.status(404).send({ error: "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 deleted = await prisma.fixedPlan.deleteMany({ where: { id: pid.data.id, userId } }); - if (deleted.count === 0) return reply.status(404).send({ error: "NOT_FOUND" }); - return reply.send({ ok: true }); + 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 plugin; +export default fixedPlansRoutes; diff --git a/api/src/server.ts b/api/src/server.ts index 657b4a4..f6cd196 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -17,6 +17,7 @@ import userRoutes from "./routes/user.js"; import authAccountRoutes from "./routes/auth-account.js"; import variableCategoriesRoutes from "./routes/variable-categories.js"; import transactionsRoutes from "./routes/transactions.js"; +import fixedPlansRoutes from "./routes/fixed-plans.js"; export type AppConfig = typeof env; @@ -944,6 +945,13 @@ await app.register(transactionsRoutes, { toBig, parseCurrencyToCents, }); +await app.register(fixedPlansRoutes, { + mutationRateLimit, + computeDepositShares, + computeWithdrawShares, + calculateNextDueDate, + toBig, +}); app.get("/site-access/status", async (req) => { if (!config.UNDER_CONSTRUCTION_ENABLED) { @@ -1410,1298 +1418,6 @@ app.post("/income", mutationRateLimit, async (req, reply) => { return result; }); -// ----- 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", - }; - }); -}); - -// ----- 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(), - 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"]); - -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 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 = toBig(computedTotalCents); - 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, - amountMode, - estimatedCents: - amountMode === "estimated" && parsed.data.estimatedCents !== undefined - ? 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", 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 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 - ? 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 - ? 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", 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 }; - }); -}); - -app.post("/fixed-plans/:id/true-up-actual", 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 = 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 = 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, - }; - }); -}); - -// ----- 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, - 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; - - // 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, - }; - const isEstimatedPlan = plan.amountMode === "estimated"; - - // 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 (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 }, - }); - - // 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({ diff --git a/api/tests/payment-rollover.test.ts b/api/tests/payment-rollover.test.ts index 349b179..381f551 100644 --- a/api/tests/payment-rollover.test.ts +++ b/api/tests/payment-rollover.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import request from "supertest"; +import { randomUUID } from "node:crypto"; +import { toZonedTime, fromZonedTime } from "date-fns-tz"; import appFactory from "./appFactory"; import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; @@ -16,15 +18,75 @@ beforeEach(async () => { }); afterAll(async () => { - await app.close(); + if (app) { + await app.close(); + } await closePrisma(); }); +function getUserMidnightFromDateOnly(timezone: string, date: Date): Date { + const zoned = toZonedTime(date, timezone); + zoned.setHours(0, 0, 0, 0); + return fromZonedTime(zoned, timezone); +} + +function calculateNextDueDateLikeServer( + 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); +} + describe("Payment-Triggered Rollover", () => { + async function getUserTimezoneOrDefault() { + const user = await prisma.user.findUnique({ + where: { id: U }, + select: { timezone: true }, + }); + return user?.timezone ?? "America/New_York"; + } + + function postTransactionsWithCsrf() { + const csrf = randomUUID().replace(/-/g, ""); + return request(app.server) + .post("/transactions") + .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`); + } + it("advances due date for weekly frequency on payment", async () => { // Create a fixed plan with weekly frequency const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "Weekly Subscription", totalCents: 1000n, @@ -38,14 +100,13 @@ describe("Payment-Triggered Rollover", () => { }); // Make payment - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 1000, planId: plan.id, + isReconciled: true, }); if (txRes.status !== 200) { @@ -58,12 +119,19 @@ describe("Payment-Triggered Rollover", () => { const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated?.fundedCents).toBe(0n); expect(updated?.currentFundedCents).toBe(0n); - expect(updated?.dueOn.toISOString()).toBe("2025-12-08T00:00:00.000Z"); + const userTimezone = await getUserTimezoneOrDefault(); + const expectedDue = calculateNextDueDateLikeServer( + new Date("2025-12-01T00:00:00Z"), + "weekly", + userTimezone + ); + expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); it("advances due date for biweekly frequency on payment", async () => { const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "Biweekly Bill", totalCents: 5000n, @@ -76,26 +144,32 @@ describe("Payment-Triggered Rollover", () => { }, }); - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 5000, planId: plan.id, + isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated?.fundedCents).toBe(0n); - expect(updated?.dueOn.toISOString()).toBe("2025-12-15T00:00:00.000Z"); + const userTimezone = await getUserTimezoneOrDefault(); + const expectedDue = calculateNextDueDateLikeServer( + new Date("2025-12-01T00:00:00Z"), + "biweekly", + userTimezone + ); + expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); it("advances due date for monthly frequency on payment", async () => { const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "Monthly Rent", totalCents: 100000n, @@ -108,27 +182,33 @@ describe("Payment-Triggered Rollover", () => { }, }); - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 100000, planId: plan.id, + isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); expect(updated?.fundedCents).toBe(0n); - expect(updated?.dueOn.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + const userTimezone = await getUserTimezoneOrDefault(); + const expectedDue = calculateNextDueDateLikeServer( + new Date("2025-12-01T00:00:00Z"), + "monthly", + userTimezone + ); + expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString()); }); - it("does not advance due date for one-time frequency", async () => { + it("deletes one-time plan after payment", async () => { const originalDueDate = new Date("2025-12-01T00:00:00Z"); const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "One-time Expense", totalCents: 2000n, @@ -141,28 +221,26 @@ describe("Payment-Triggered Rollover", () => { }, }); - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 2000, planId: plan.id, + isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); - expect(updated?.fundedCents).toBe(0n); - // Due date should remain unchanged for one-time expenses - expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString()); + expect(updated).toBeNull(); }); - it("does not advance due date when no frequency is set", async () => { + it("treats null frequency as one-time and deletes plan after payment", async () => { const originalDueDate = new Date("2025-12-01T00:00:00Z"); const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "Manual Bill", totalCents: 3000n, @@ -175,27 +253,25 @@ describe("Payment-Triggered Rollover", () => { }, }); - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 3000, planId: plan.id, + isReconciled: true, }); expect(txRes.status).toBe(200); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); - expect(updated?.fundedCents).toBe(0n); - // Due date should remain unchanged when no frequency - expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString()); + expect(updated).toBeNull(); }); it("prevents payment when insufficient funded amount", async () => { const plan = await prisma.fixedPlan.create({ data: { + id: randomUUID(), userId: U, name: "Underfunded Bill", totalCents: 10000n, @@ -209,18 +285,17 @@ describe("Payment-Triggered Rollover", () => { }); // Try to pay more than funded amount - const txRes = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const txRes = await postTransactionsWithCsrf() .send({ occurredAtISO: "2025-11-27T12:00:00Z", kind: "fixed_payment", amountCents: 10000, planId: plan.id, + isReconciled: true, }); expect(txRes.status).toBe(400); - expect(txRes.body.code).toBe("OVERDRAFT_PLAN"); + expect(txRes.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET"); // Plan should remain unchanged const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); diff --git a/api/tests/transactions.test.ts b/api/tests/transactions.test.ts index 09b2e99..f656187 100644 --- a/api/tests/transactions.test.ts +++ b/api/tests/transactions.test.ts @@ -1,7 +1,8 @@ import request from "supertest"; import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { randomUUID } from "node:crypto"; import appFactory from "./appFactory"; -import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; +import { prisma, resetUser, ensureUser, U, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; @@ -11,7 +12,9 @@ beforeAll(async () => { }); afterAll(async () => { - await app.close(); + if (app) { + await app.close(); + } await closePrisma(); }); @@ -23,16 +26,22 @@ describe("GET /transactions", () => { await resetUser(U); await ensureUser(U); - catId = cid("c"); - planId = pid("p"); - - await prisma.variableCategory.create({ - data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n }, - }); - - await prisma.fixedPlan.create({ + const category = await prisma.variableCategory.create({ data: { - id: planId, + id: randomUUID(), + userId: U, + name: "Groceries", + percent: 100, + priority: 1, + isSavings: false, + balanceCents: 5000n, + }, + }); + catId = category.id; + + const plan = await prisma.fixedPlan.create({ + data: { + id: randomUUID(), userId: U, name: "Rent", totalCents: 10000n, @@ -43,6 +52,7 @@ describe("GET /transactions", () => { fundingMode: "auto-on-deposit", }, }); + planId = plan.id; await prisma.transaction.createMany({ data: [ @@ -100,16 +110,31 @@ describe("POST /transactions", () => { let catId: string; let planId: string; + function postTransactionsWithCsrf() { + const csrf = randomUUID().replace(/-/g, ""); + return request(app.server) + .post("/transactions") + .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`); + } + + function patchWithCsrf(path: string) { + const csrf = randomUUID().replace(/-/g, ""); + return request(app.server) + .patch(path) + .set("x-user-id", U) + .set("x-csrf-token", csrf) + .set("Cookie", `csrf=${csrf}`); + } + beforeEach(async () => { await resetUser(U); await ensureUser(U); - catId = cid("cat"); - planId = pid("plan"); - - await prisma.variableCategory.create({ + const category = await prisma.variableCategory.create({ data: { - id: catId, + id: randomUUID(), userId: U, name: "Dining", percent: 100, @@ -118,10 +143,11 @@ describe("POST /transactions", () => { balanceCents: 5000n, }, }); + catId = category.id; - await prisma.fixedPlan.create({ + const plan = await prisma.fixedPlan.create({ data: { - id: planId, + id: randomUUID(), userId: U, name: "Loan", totalCents: 10000n, @@ -132,12 +158,11 @@ describe("POST /transactions", () => { fundingMode: "auto-on-deposit", }, }); + planId = plan.id; }); it("spends from a variable category and updates balance", async () => { - const res = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const res = await postTransactionsWithCsrf() .send({ kind: "variable_spend", amountCents: 2000, @@ -157,10 +182,8 @@ describe("POST /transactions", () => { expect(tx.isReconciled).toBe(true); }); - it("prevents overdrawing fixed plans", async () => { - const res = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + it("returns insufficient budget when fixed payment requires unavailable top-up", async () => { + const res = await postTransactionsWithCsrf() .send({ kind: "fixed_payment", amountCents: 400000, // exceeds funded @@ -169,13 +192,11 @@ describe("POST /transactions", () => { }); expect(res.status).toBe(400); - expect(res.body.code).toBe("OVERDRAFT_PLAN"); + expect(res.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET"); }); it("updates note/receipt and reconciliation via patch", async () => { - const created = await request(app.server) - .post("/transactions") - .set("x-user-id", U) + const created = await postTransactionsWithCsrf() .send({ kind: "variable_spend", amountCents: 1000, @@ -185,9 +206,7 @@ describe("POST /transactions", () => { expect(created.status).toBe(200); const txId = created.body.id; - const res = await request(app.server) - .patch(`/transactions/${txId}`) - .set("x-user-id", U) + const res = await patchWithCsrf(`/transactions/${txId}`) .send({ note: "Cleared", isReconciled: true, diff --git a/docs/api-phase5-move-log.md b/docs/api-phase5-move-log.md new file mode 100644 index 0000000..a7a71b7 --- /dev/null +++ b/docs/api-phase5-move-log.md @@ -0,0 +1,121 @@ +# API Phase 5 Move Log + +Date: 2026-03-17 +Scope: Move `fixed-plans` endpoints out of `api/src/server.ts` into a dedicated route module. + +## Route Registration Changes +- Added fixed-plans route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:20) +- Registered fixed-plans routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:948) +- New canonical route module: [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71) +- Removed inline fixed-plans route blocks from `server.ts` to avoid duplicate registration: + - `PATCH /fixed-plans/:id/early-funding` + - `POST /fixed-plans/:id/attempt-final-funding` + - `PATCH /fixed-plans/:id/mark-unpaid` + - `POST /fixed-plans/:id/fund-from-available` + - `POST /fixed-plans/:id/catch-up-funding` + - `POST /fixed-plans` + - `PATCH /fixed-plans/:id` + - `DELETE /fixed-plans/:id` + - `POST /fixed-plans/:id/true-up-actual` + - `GET /fixed-plans/due` + - `POST /fixed-plans/:id/pay-now` + +## Endpoint Movements + +1. `PATCH /fixed-plans/:id/early-funding` +- Original: `server.ts` line 1414 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71) +- References: + - [EarlyPaymentPromptModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyPaymentPromptModal.tsx:34) + - [EarlyFundingModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyFundingModal.tsx:19) + +2. `POST /fixed-plans/:id/attempt-final-funding` +- Original: `server.ts` line 1475 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:131) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:58) + - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:219) + +3. `PATCH /fixed-plans/:id/mark-unpaid` +- Original: `server.ts` line 1635 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:287) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:84) + +4. `POST /fixed-plans/:id/fund-from-available` +- Original: `server.ts` line 1674 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:325) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:95) + - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:461) + +5. `POST /fixed-plans/:id/catch-up-funding` +- Original: `server.ts` line 1828 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:478) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:106) + - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:512) + +6. `POST /fixed-plans` +- Original: `server.ts` line 2036 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:659) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:39) + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:449) + - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:43) + +7. `PATCH /fixed-plans/:id` +- Original: `server.ts` line 2122 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:747) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:41) + - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:502) + +8. `DELETE /fixed-plans/:id` +- Original: `server.ts` line 2239 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:866) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:42) + - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:443) + +9. `POST /fixed-plans/:id/true-up-actual` +- Original: `server.ts` line 2285 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:911) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:108) + - [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:549) + - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:79) + +10. `GET /fixed-plans/due` +- Original: `server.ts` line 2429 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1054) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:45) + - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:363) + +11. `POST /fixed-plans/:id/pay-now` +- Original: `server.ts` line 2495 +- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1118) +- References: + - [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:77) + - [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:649) + - [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:170) + +## Helper Ownership in Phase 5 +- Shared helper injection from `server.ts`: + - `mutationRateLimit` + - `computeDepositShares` + - `computeWithdrawShares` + - `calculateNextDueDate` + - `toBig` +- Route-local helper: + - `DAY_MS` constant for date-window computations + - `PlanBody` / `PlanAmountMode` zod schemas + +## Verification +1. Build +- `cd api && npm run build` ✅ + +2. Focused tests +- `cd api && npm run test -- tests/fixed-plans.estimated-true-up.test.ts tests/payment-rollover.test.ts tests/transactions.test.ts` +- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions. diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md index 4c7ee07..d1b7e5d 100644 --- a/docs/api-refactor-lightweight-plan.md +++ b/docs/api-refactor-lightweight-plan.md @@ -3,9 +3,14 @@ ## Goal Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves. -Current state (2026-03-15): -- `server.ts` has ~4.8k lines and 50 endpoint registrations. -- Duplicate endpoint signatures also exist in `api/src/routes/*` but are not currently registered. +Current state (2026-03-17): +- `server.ts` still holds most business routes, but Phases 1-5 are complete. +- Completed move logs: + - `docs/api-phase1-move-log.md` + - `docs/api-phase2-move-log.md` + - `docs/api-phase3-move-log.md` + - `docs/api-phase4-move-log.md` + - `docs/api-phase5-move-log.md` ## Refactor Guardrails 1. Keep route behavior identical while moving code. @@ -26,7 +31,7 @@ When moving a domain: 4. Confirm no duplicate route registrations remain. ## Shared Helpers (Phase 0) -Create these helpers first to keep endpoint moves lightweight: +Create and/or finish these helpers to keep endpoint moves lightweight: 1. `api/src/services/user-context.ts` - `getUserTimezone(...)` @@ -49,15 +54,108 @@ Create these helpers first to keep endpoint moves lightweight: - Standard typed error object and response body builder. - Keeps endpoint error handling consistent. -## Move Order (Incremental) -1. Low-risk endpoints: `health`, `session`, `me`, `user/config`. -2. `auth` + `account` endpoints. -3. `variable-categories` endpoints. -4. `transactions` endpoints. -5. `fixed-plans` endpoints. -6. `income`, `budget`, `payday` endpoints. -7. `dashboard` + `crisis-status`. -8. `admin` endpoints. +## Progress Snapshot +Completed: +1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`). +2. Phase 2: `auth` + `account` endpoints. +3. Phase 3: `variable-categories` endpoints. +4. Phase 4: `transactions` endpoints. +5. Phase 5: `fixed-plans` endpoints. + +Remaining: +1. Phase 6: `income`, `budget`, `payday` endpoints. +2. Phase 7: `dashboard` + `crisis-status`. +3. Phase 8: `admin` + site access endpoints. +4. Phase 9: final cleanup and helper consolidation. + +## Remaining Plan (Detailed) + +### Phase 5: Fixed Plans Domain +Move these routes out of `server.ts` into `api/src/routes/fixed-plans.ts` (or split module if needed): +1. `PATCH /fixed-plans/:id/early-funding` +2. `POST /fixed-plans/:id/attempt-final-funding` +3. `PATCH /fixed-plans/:id/mark-unpaid` +4. `POST /fixed-plans/:id/fund-from-available` +5. `POST /fixed-plans/:id/catch-up-funding` +6. `POST /fixed-plans` +7. `PATCH /fixed-plans/:id` +8. `DELETE /fixed-plans/:id` +9. `POST /fixed-plans/:id/true-up-actual` +10. `GET /fixed-plans/due` +11. `POST /fixed-plans/:id/pay-now` + +Primary risk: +- Payment/funding workflows are tightly coupled with available budget math and rollover rules. + +Test focus: +- `api/tests/fixed-plans*.test.ts` +- `api/tests/payment-rollover.test.ts` + +### Phase 6: Income, Budget, Payday Domain +Move these routes into a dedicated module (e.g., `api/src/routes/budget-income.ts`): +1. `POST /income` +2. `GET /income/history` +3. `POST /income/preview` +4. `POST /budget/allocate` +5. `POST /budget/fund` +6. `POST /budget/reconcile` +7. `GET /payday/status` +8. `POST /payday/dismiss` + +Primary risk: +- Budget-session synchronization and allocator side effects. + +Test focus: +- Income/budget allocation tests +- Any tests asserting payday status/dismiss behavior + +### Phase 7: Dashboard Read Domain +Move read endpoints into `api/src/routes/dashboard.ts`: +1. `GET /dashboard` +2. `GET /crisis-status` + +Primary risk: +- Derived numbers diverging between dashboard and rebalance/session APIs. + +Test focus: +- Dashboard API contract checks and UI smoke verification. + +### Phase 8: Admin and Site Access Domain +Move operational endpoints into `api/src/routes/admin.ts` and/or `api/src/routes/site-access.ts`: +1. `POST /admin/rollover` +2. `GET /site-access/status` +3. `POST /site-access/unlock` +4. `POST /site-access/lock` + +Primary risk: +- Lockout/maintenance flow regressions and accidental open access. + +Test focus: +- Site access flow tests +- Admin rollover auth/permission checks + +### Phase 9: Final Cleanup +1. Remove dead helper duplicates from `server.ts` and route modules. +2. Consolidate common helpers into `api/src/services/*`. +3. Normalize error envelopes where safe (no contract change unless explicitly planned). +4. Re-run full API/security suites and perform deployment smoke checks. + +## Reference Audit Requirement (Per Move) +For every endpoint moved: +1. Record original location in `server.ts`. +2. Record new route module location. +3. Record frontend references (`web/src/api/*`, hooks, pages). +4. Record test references. +5. Add a phase move log under `docs/api-phaseX-move-log.md`. + +## Verification Steps (Per Phase) +1. Build: +- `cd api && npm run build` +2. Run focused tests for moved domain. +3. Run security suites relevant to moved endpoints. +4. Deploy. +5. Run production endpoint smoke checks for the moved routes. +6. Confirm logs show expected status codes (no unexplained 401/403/500 shifts). ## Definition of Done per Phase 1. Endpoints compile and register once.