From 181c3bdc9e60be65938e2bbf112ac8ba05ad00d1 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Tue, 17 Mar 2026 09:00:48 -0500 Subject: [PATCH] phase 4: simplify all transaction routes --- api/src/routes/transactions.ts | 781 +++++++++++++++++++++++++++++++-- api/src/server.ts | 743 +------------------------------ docs/api-phase4-move-log.md | 57 +++ 3 files changed, 805 insertions(+), 776 deletions(-) create mode 100644 docs/api-phase4-move-log.md diff --git a/api/src/routes/transactions.ts b/api/src/routes/transactions.ts index 8f8c3ff..4dedf39 100644 --- a/api/src/routes/transactions.ts +++ b/api/src/routes/transactions.ts @@ -1,71 +1,764 @@ -// api/src/routes/transactions.ts -import fp from "fastify-plugin"; +import type { FastifyPluginAsync } from "fastify"; +import { Prisma } from "@prisma/client"; import { z } from "zod"; import { getUserDateRangeFromDateOnly } from "../allocator.js"; -const Query = z.object({ - from: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional(), // YYYY-MM-DD - to: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional(), - kind: z.enum(["variable_spend", "fixed_payment"]).optional(), - q: z.string().trim().optional(), - page: z.coerce.number().int().positive().default(1), - limit: z.coerce.number().int().positive().max(100).default(20), -}); +type RateLimitRouteOptions = { + config: { + rateLimit: { + max: number; + timeWindow: number; + keyGenerator?: (req: any) => string; + }; + }; +}; -export default fp(async function transactionsRoute(app) { - app.get("/transactions", async (req, reply) => { - if (typeof req.userId !== "string") { - return reply.code(401).send({ message: "Unauthorized" }); +type PercentCategory = { + id: string; + percent: number; + balanceCents: bigint | null; +}; + +type ShareResult = + | { ok: true; shares: Array<{ id: string; share: number }> } + | { ok: false; reason: string }; + +type TransactionsRoutesOptions = { + mutationRateLimit: RateLimitRouteOptions; + computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult; + computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult; + computeOverdraftShares: (categories: PercentCategory[], amountCents: number) => ShareResult; + calculateNextDueDate: (currentDueDate: Date, frequency: string, timezone?: string) => Date; + toBig: (n: number | string | bigint) => bigint; + parseCurrencyToCents: (value: string) => number; +}; + +const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); + +const transactionsRoutes: FastifyPluginAsync = async ( + app, + opts +) => { + app.post("/transactions", opts.mutationRateLimit, async (req, reply) => { + const Body = z + .object({ + kind: z.enum(["variable_spend", "fixed_payment"]), + amountCents: z.number().int().positive(), + occurredAtISO: z.string().datetime(), + categoryId: z.string().uuid().optional(), + planId: z.string().uuid().optional(), + note: z.string().trim().max(500).optional(), + receiptUrl: z.string().trim().url().max(2048).optional(), + isReconciled: z.boolean().optional(), + allowOverdraft: z.boolean().optional(), + useAvailableBudget: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + if (data.kind === "variable_spend") { + if (!data.categoryId && !data.useAvailableBudget) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "categoryId required for variable_spend", + path: ["categoryId"], + }); + } + if (data.planId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planId not allowed for variable_spend", + path: ["planId"], + }); + } + } + if (data.kind === "fixed_payment") { + if (!data.planId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "planId required for fixed_payment", + path: ["planId"], + }); + } + if (data.categoryId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "categoryId not allowed for fixed_payment", + path: ["categoryId"], + }); + } + } + }); + + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); } + + const { + kind, + amountCents, + occurredAtISO, + categoryId, + planId, + note, + receiptUrl, + isReconciled, + allowOverdraft, + useAvailableBudget, + } = parsed.data; const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; + const amt = opts.toBig(amountCents); + + return await app.prisma.$transaction(async (tx) => { + let deletePlanAfterPayment = false; + let paidAmount = amountCents; + let updatedDueOn: Date | undefined; + + if (kind === "variable_spend") { + if (useAvailableBudget) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + if (amountCents > availableBudget && !allowOverdraft) { + const overdraftAmount = amountCents - availableBudget; + return reply.code(400).send({ + ok: false, + code: "OVERDRAFT_CONFIRMATION", + message: `This will overdraft your available budget by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, + overdraftAmount, + categoryName: "available budget", + currentBalance: availableBudget, + }); + } + + const shareResult = allowOverdraft + ? opts.computeOverdraftShares(categories, amountCents) + : opts.computeWithdrawShares(categories, amountCents); + + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to cover this spend." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + } else { + if (!categoryId) { + return reply.code(400).send({ message: "categoryId required" }); + } + const cat = await tx.variableCategory.findFirst({ + where: { id: categoryId, userId }, + }); + if (!cat) return reply.code(404).send({ message: "Category not found" }); + + const bal = cat.balanceCents ?? 0n; + if (amt > bal && !allowOverdraft) { + const overdraftAmount = Number(amt - bal); + return reply.code(400).send({ + ok: false, + code: "OVERDRAFT_CONFIRMATION", + message: `This will overdraft ${cat.name} by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, + overdraftAmount, + categoryName: cat.name, + currentBalance: Number(bal), + }); + } + const updated = await tx.variableCategory.updateMany({ + where: { id: cat.id, userId }, + data: { balanceCents: bal - amt }, + }); + if (updated.count === 0) { + return reply.code(404).send({ message: "Category not found" }); + } + } + } else { + if (!planId) { + return reply.code(400).send({ message: "planId required" }); + } + const plan = await tx.fixedPlan.findFirst({ + where: { id: planId, userId }, + }); + if (!plan) return reply.code(404).send({ message: "Plan not found" }); + const userTimezone = + ( + await tx.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }) + )?.timezone ?? "America/New_York"; + + const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const totalAmount = Number(plan.totalCents ?? 0n); + const isOneTime = !plan.frequency || plan.frequency === "one-time"; + const isReconciledPayment = !!isReconciled; + + if (!isReconciledPayment) { + const remainingNeeded = Math.max(0, totalAmount - fundedAmount); + const amountToFund = Math.min(amountCents, remainingNeeded); + + if (amountToFund <= 0) { + return reply.code(400).send({ message: "Plan is already fully funded." }); + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + if (availableBudget < amountToFund) { + const err: any = new Error("Insufficient available budget to fund this amount."); + err.statusCode = 400; + err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; + err.availableBudget = availableBudget; + err.shortage = amountToFund; + throw err; + } + + const shareResult = opts.computeWithdrawShares(categories, amountToFund); + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to fund this amount." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(amountToFund), + incomeId: null, + }, + }); + + const newFunded = fundedAmount + amountToFund; + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + lastFundingDate: new Date(), + lastFundedPayPeriod: new Date(), + needsFundingThisPeriod: newFunded < totalAmount, + }, + }); + + paidAmount = amountToFund; + + if (!isOneTime && newFunded >= totalAmount) { + if (plan.frequency && plan.frequency !== "one-time") { + updatedDueOn = opts.calculateNextDueDate(plan.dueOn, plan.frequency, userTimezone); + } else { + updatedDueOn = plan.dueOn ?? undefined; + } + } + } else { + const normalizedPaid = Math.min(amountCents, totalAmount); + const shortage = Math.max(0, normalizedPaid - fundedAmount); + const effectiveFunded = fundedAmount + shortage; + + if (shortage > 0) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + + const availableBudget = categories.reduce( + (sum, cat) => sum + Number(cat.balanceCents ?? 0n), + 0 + ); + + if (availableBudget < shortage) { + const err: any = new Error("Insufficient available budget to cover this payment."); + err.statusCode = 400; + err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; + err.availableBudget = availableBudget; + err.shortage = shortage; + throw err; + } + + const shareResult = opts.computeWithdrawShares(categories, shortage); + if (!shareResult.ok) { + const err: any = new Error( + shareResult.reason === "no_percent" + ? "No category percentages available." + : "Insufficient category balances to cover this payment." + ); + err.statusCode = 400; + err.code = + shareResult.reason === "no_percent" + ? "NO_CATEGORY_PERCENT" + : "INSUFFICIENT_CATEGORY_BALANCES"; + throw err; + } + + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { decrement: BigInt(s.share) } }, + }); + } + + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(shortage), + incomeId: null, + }, + }); + } + + paidAmount = normalizedPaid; + + if (paidAmount >= totalAmount) { + if (isOneTime) { + deletePlanAfterPayment = true; + } else { + let frequency = plan.frequency; + if (!frequency && plan.paymentSchedule) { + const schedule = plan.paymentSchedule as any; + frequency = schedule.frequency; + } + if (frequency && frequency !== "one-time") { + updatedDueOn = opts.calculateNextDueDate(plan.dueOn, frequency, userTimezone); + } else { + updatedDueOn = plan.dueOn ?? undefined; + } + + const updateData: any = { + fundedCents: 0n, + currentFundedCents: 0n, + isOverdue: false, + overdueAmount: 0n, + overdueSince: null, + needsFundingThisPeriod: plan.paymentSchedule ? true : false, + }; + if (updatedDueOn) { + updateData.dueOn = updatedDueOn; + updateData.nextPaymentDate = plan.autoPayEnabled ? updatedDueOn : null; + } + + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: updateData, + }); + } + } else if (paidAmount > 0 && paidAmount < totalAmount) { + const refundAmount = Math.max(0, effectiveFunded - paidAmount); + + if (refundAmount > 0) { + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: planId, + amountCents: BigInt(-refundAmount), + incomeId: null, + }, + }); + } + + const remainingBalance = totalAmount - paidAmount; + const updatedPlan = await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + fundedCents: 0n, + currentFundedCents: 0n, + isOverdue: true, + overdueAmount: BigInt(remainingBalance), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + select: { id: true, dueOn: true }, + }); + updatedDueOn = updatedPlan.dueOn ?? undefined; + } else { + await tx.fixedPlan.update({ + where: { id: plan.id }, + data: { + isOverdue: true, + overdueAmount: BigInt(totalAmount - fundedAmount), + overdueSince: plan.overdueSince ?? new Date(), + needsFundingThisPeriod: true, + }, + }); + } + } + } + + const row = await tx.transaction.create({ + data: { + userId, + occurredAt: new Date(occurredAtISO), + kind, + amountCents: opts.toBig(paidAmount), + categoryId: kind === "variable_spend" ? categoryId ?? null : null, + planId: kind === "fixed_payment" ? planId ?? null : null, + note: note?.trim() ? note.trim() : null, + receiptUrl: receiptUrl ?? null, + isReconciled: isReconciled ?? false, + isAutoPayment: false, + }, + select: { id: true, kind: true, amountCents: true, occurredAt: true }, + }); + + if (kind === "fixed_payment") { + if (deletePlanAfterPayment) { + await tx.fixedPlan.deleteMany({ where: { id: planId, userId } }); + } + return { + ...row, + planId, + nextDueOn: updatedDueOn || undefined, + } as any; + } + + return row; + }); + }); + + app.get("/transactions", async (req, reply) => { + const Query = z.object({ + from: z.string().refine(isDate, "YYYY-MM-DD").optional(), + to: z.string().refine(isDate, "YYYY-MM-DD").optional(), + kind: z.enum(["variable_spend", "fixed_payment"]).optional(), + q: z.string().trim().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), + bucketId: z.string().min(1).optional(), + categoryId: z.string().min(1).optional(), + sort: z.enum(["date", "amount", "kind", "bucket"]).optional(), + direction: z.enum(["asc", "desc"]).optional(), + }); const parsed = Query.safeParse(req.query); if (!parsed.success) { - return reply - .code(400) - .send({ message: "Invalid query", issues: parsed.error.issues }); + return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues }); } - const { from, to, kind, q, page, limit } = parsed.data; - const where: any = { userId }; + const { + from, + to, + kind, + q, + bucketId: rawBucketId, + categoryId, + sort = "date", + direction = "desc", + page, + limit, + } = parsed.data; + const bucketId = rawBucketId ?? categoryId; + const userId = req.userId; + const userTimezone = + ( + await app.prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }) + )?.timezone ?? "America/New_York"; + + const where: Record = { userId }; + if (from || to) { where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to); } - if (kind) where.kind = kind; + if (kind) { + where.kind = kind; + } else { + where.kind = { in: ["variable_spend", "fixed_payment"] }; + } + const flexibleOr: any[] = []; if (typeof q === "string" && q.trim() !== "") { - const ors: any[] = []; - const asNumber = Number(q); - if (Number.isFinite(asNumber)) { - ors.push({ amountCents: BigInt(asNumber) }); - } - if (ors.length > 0) { - where.OR = ors; - } -} + const qTrim = q.trim(); + const asCents = opts.parseCurrencyToCents(qTrim); + if (asCents > 0) { + flexibleOr.push({ amountCents: opts.toBig(asCents) }); + } + flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } }); + flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } }); + flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } }); + } + if (bucketId) { + if (!kind || kind === "variable_spend") { + flexibleOr.push({ categoryId: bucketId }); + } + if (!kind || kind === "fixed_payment") { + flexibleOr.push({ planId: bucketId }); + } + } + if (flexibleOr.length > 0) { + const existing = Array.isArray((where as any).OR) ? (where as any).OR : []; + (where as any).OR = [...existing, ...flexibleOr]; + } const skip = (page - 1) * limit; + const orderDirection = direction === "asc" ? "asc" : "desc"; + const orderBy = + sort === "amount" + ? [ + { amountCents: orderDirection as Prisma.SortOrder }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : sort === "kind" + ? [ + { kind: orderDirection as Prisma.SortOrder }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : sort === "bucket" + ? [ + { category: { name: orderDirection as Prisma.SortOrder } }, + { plan: { name: orderDirection as Prisma.SortOrder } }, + { occurredAt: "desc" as Prisma.SortOrder }, + ] + : [{ occurredAt: orderDirection as Prisma.SortOrder }]; - const [total, items] = await Promise.all([ + const txInclude = Prisma.validator()({ + category: { select: { name: true } }, + plan: { select: { name: true } }, + }); + type TxWithRelations = Prisma.TransactionGetPayload<{ + include: typeof txInclude; + }>; + + const [total, itemsRaw] = await Promise.all([ app.prisma.transaction.count({ where }), - app.prisma.transaction.findMany({ + (app.prisma.transaction.findMany({ where, - orderBy: { occurredAt: "desc" }, + orderBy, skip, take: limit, - select: { id: true, kind: true, amountCents: true, occurredAt: true }, - }), + include: txInclude, + }) as Promise), ]); + const items = itemsRaw.map((tx) => ({ + id: tx.id, + kind: tx.kind, + amountCents: tx.amountCents, + occurredAt: tx.occurredAt, + categoryId: tx.categoryId, + categoryName: + tx.category?.name ?? + (tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null), + planId: tx.planId, + planName: tx.plan?.name ?? null, + note: tx.note ?? null, + receiptUrl: tx.receiptUrl ?? null, + isReconciled: !!tx.isReconciled, + isAutoPayment: !!tx.isAutoPayment, + })); + return { items, page, limit, total }; }); -}); + + app.patch("/transactions/:id", opts.mutationRateLimit, async (req, reply) => { + const Params = z.object({ id: z.string().min(1) }); + const Body = z.object({ + note: z.string().trim().max(500).or(z.literal("")).optional(), + receiptUrl: z.string().trim().max(2048).url().or(z.literal("")).optional(), + isReconciled: z.boolean().optional(), + }); + const params = Params.safeParse(req.params); + const parsed = Body.safeParse(req.body); + if (!params.success || !parsed.success) { + return reply.code(400).send({ message: "Invalid payload" }); + } + const userId = req.userId; + const id = params.data.id; + + if ( + parsed.data.note === undefined && + parsed.data.receiptUrl === undefined && + parsed.data.isReconciled === undefined + ) { + return reply.code(400).send({ message: "No fields to update" }); + } + + const existing = await app.prisma.transaction.findFirst({ where: { id, userId } }); + if (!existing) return reply.code(404).send({ message: "Transaction not found" }); + + const data: Prisma.TransactionUpdateInput = {}; + if (parsed.data.note !== undefined) { + const value = parsed.data.note.trim(); + data.note = value.length > 0 ? value : null; + } + if (parsed.data.receiptUrl !== undefined) { + const url = parsed.data.receiptUrl.trim(); + data.receiptUrl = url.length > 0 ? url : null; + } + if (parsed.data.isReconciled !== undefined) { + data.isReconciled = parsed.data.isReconciled; + } + + const updated = await app.prisma.transaction.updateMany({ + where: { id, userId }, + data, + }); + if (updated.count === 0) return reply.code(404).send({ message: "Transaction not found" }); + + const refreshed = await app.prisma.transaction.findFirst({ + where: { id, userId }, + select: { + id: true, + note: true, + receiptUrl: true, + isReconciled: true, + }, + }); + + return refreshed; + }); + + app.delete("/transactions/:id", opts.mutationRateLimit, async (req, reply) => { + const Params = z.object({ id: z.string().min(1) }); + const params = Params.safeParse(req.params); + if (!params.success) { + return reply.code(400).send({ message: "Invalid transaction id" }); + } + + const userId = req.userId; + const id = params.data.id; + + return await app.prisma.$transaction(async (tx) => { + const existing = await tx.transaction.findFirst({ + where: { id, userId }, + }); + if (!existing) return reply.code(404).send({ message: "Transaction not found" }); + + const amountCents = Number(existing.amountCents ?? 0n); + if (existing.kind === "variable_spend") { + if (!existing.categoryId) { + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const shareResult = opts.computeDepositShares(categories, amountCents); + if (!shareResult.ok) { + return reply.code(400).send({ message: "No category percentages available." }); + } + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { increment: BigInt(s.share) } }, + }); + } + } else { + const updated = await tx.variableCategory.updateMany({ + where: { id: existing.categoryId, userId }, + data: { balanceCents: { increment: BigInt(amountCents) } }, + }); + if (updated.count === 0) { + return reply.code(404).send({ message: "Category not found" }); + } + } + } else if (existing.kind === "fixed_payment") { + if (!existing.planId) { + return reply.code(400).send({ message: "Transaction missing planId" }); + } + const plan = await tx.fixedPlan.findFirst({ + where: { id: existing.planId, userId }, + }); + if (!plan) { + return reply.code(404).send({ message: "Fixed plan not found" }); + } + + const categories = await tx.variableCategory.findMany({ + where: { userId }, + select: { id: true, percent: true, balanceCents: true }, + orderBy: [{ priority: "asc" }, { name: "asc" }], + }); + const shareResult = opts.computeDepositShares(categories, amountCents); + if (!shareResult.ok) { + return reply.code(400).send({ message: "No category percentages available." }); + } + for (const s of shareResult.shares) { + if (s.share <= 0) continue; + await tx.variableCategory.update({ + where: { id: s.id }, + data: { balanceCents: { increment: BigInt(s.share) } }, + }); + } + await tx.allocation.create({ + data: { + userId, + kind: "fixed", + toId: existing.planId, + amountCents: BigInt(-amountCents), + incomeId: null, + }, + }); + + const fundedBefore = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); + const total = Number(plan.totalCents ?? 0n); + const newFunded = Math.max(0, fundedBefore - amountCents); + const updatedPlan = await tx.fixedPlan.updateMany({ + where: { id: plan.id, userId }, + data: { + fundedCents: BigInt(newFunded), + currentFundedCents: BigInt(newFunded), + needsFundingThisPeriod: newFunded < total, + }, + }); + if (updatedPlan.count === 0) { + return reply.code(404).send({ message: "Fixed plan not found" }); + } + } + + const deleted = await tx.transaction.deleteMany({ where: { id, userId } }); + if (deleted.count === 0) return reply.code(404).send({ message: "Transaction not found" }); + + return { ok: true, id }; + }); + }); +}; + +export default transactionsRoutes; diff --git a/api/src/server.ts b/api/src/server.ts index 7799fde..657b4a4 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -8,7 +8,7 @@ import nodemailer from "nodemailer"; import { env } from "./env.js"; import { PrismaClient, Prisma } from "@prisma/client"; import { z } from "zod"; -import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js"; +import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { rolloverFixedPlans } from "./jobs/rollover.js"; import healthRoutes from "./routes/health.js"; @@ -16,6 +16,7 @@ import sessionRoutes from "./routes/session.js"; 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"; export type AppConfig = typeof env; @@ -165,7 +166,6 @@ const parseCurrencyToCents = (value: string): number => { const parsed = Number.parseFloat(normalized || "0"); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; }; -const isDate = (s?: string) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); const addMonths = (date: Date, months: number) => { const next = new Date(date); next.setMonth(next.getMonth() + months); @@ -935,6 +935,15 @@ await app.register(variableCategoriesRoutes, { mutationRateLimit, computeDepositShares, }); +await app.register(transactionsRoutes, { + mutationRateLimit, + computeDepositShares, + computeWithdrawShares, + computeOverdraftShares, + calculateNextDueDate, + toBig, + parseCurrencyToCents, +}); app.get("/site-access/status", async (req) => { if (!config.UNDER_CONSTRUCTION_ENABLED) { @@ -1401,428 +1410,6 @@ app.post("/income", mutationRateLimit, async (req, reply) => { return result; }); -// ----- Transactions: create ----- -app.post("/transactions", mutationRateLimit, async (req, reply) => { - const Body = z - .object({ - kind: z.enum(["variable_spend", "fixed_payment"]), - amountCents: z.number().int().positive(), - occurredAtISO: z.string().datetime(), - categoryId: z.string().uuid().optional(), - planId: z.string().uuid().optional(), - note: z.string().trim().max(500).optional(), - receiptUrl: z - .string() - .trim() - .url() - .max(2048) - .optional(), - isReconciled: z.boolean().optional(), - allowOverdraft: z.boolean().optional(), // Allow spending more than balance - useAvailableBudget: z.boolean().optional(), // Spend from total available budget - }) - .superRefine((data, ctx) => { - if (data.kind === "variable_spend") { - if (!data.categoryId && !data.useAvailableBudget) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "categoryId required for variable_spend", - path: ["categoryId"], - }); - } - if (data.planId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "planId not allowed for variable_spend", - path: ["planId"], - }); - } - } - if (data.kind === "fixed_payment") { - if (!data.planId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "planId required for fixed_payment", - path: ["planId"], - }); - } - if (data.categoryId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "categoryId not allowed for fixed_payment", - path: ["categoryId"], - }); - } - } - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid payload" }); - } - - const { kind, amountCents, occurredAtISO, categoryId, planId, note, receiptUrl, isReconciled, allowOverdraft, useAvailableBudget } = parsed.data; - const userId = req.userId; - const amt = toBig(amountCents); - - return await app.prisma.$transaction(async (tx) => { - let deletePlanAfterPayment = false; - let paidAmount = amountCents; - // Track updated next due date if we modify a fixed plan - let updatedDueOn: Date | undefined; - if (kind === "variable_spend") { - if (useAvailableBudget) { - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - }); - const availableBudget = categories.reduce( - (sum, cat) => sum + Number(cat.balanceCents ?? 0n), - 0 - ); - if (amountCents > availableBudget && !allowOverdraft) { - const overdraftAmount = amountCents - availableBudget; - return reply.code(400).send({ - ok: false, - code: "OVERDRAFT_CONFIRMATION", - message: `This will overdraft your available budget by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, - overdraftAmount, - categoryName: "available budget", - currentBalance: availableBudget, - }); - } - - const shareResult = allowOverdraft - ? computeOverdraftShares(categories, amountCents) - : computeWithdrawShares(categories, amountCents); - - if (!shareResult.ok) { - const err: any = new Error( - shareResult.reason === "no_percent" - ? "No category percentages available." - : "Insufficient category balances to cover this spend." - ); - err.statusCode = 400; - err.code = - shareResult.reason === "no_percent" - ? "NO_CATEGORY_PERCENT" - : "INSUFFICIENT_CATEGORY_BALANCES"; - throw err; - } - - for (const s of shareResult.shares) { - if (s.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: s.id }, - data: { balanceCents: { decrement: BigInt(s.share) } }, - }); - } - } else { - if (!categoryId) { - return reply.code(400).send({ message: "categoryId required" }); - } - const cat = await tx.variableCategory.findFirst({ - where: { id: categoryId, userId }, - }); - if (!cat) return reply.code(404).send({ message: "Category not found" }); - - const bal = cat.balanceCents ?? 0n; - if (amt > bal && !allowOverdraft) { - // Ask for confirmation before allowing overdraft - const overdraftAmount = Number(amt - bal); - return reply.code(400).send({ - ok: false, - code: "OVERDRAFT_CONFIRMATION", - message: `This will overdraft ${cat.name} by $${(overdraftAmount / 100).toFixed(2)}. The negative balance will be recovered from your next income.`, - overdraftAmount, - categoryName: cat.name, - currentBalance: Number(bal), - }); - } - const updated = await tx.variableCategory.updateMany({ - where: { id: cat.id, userId }, - data: { balanceCents: bal - amt }, // Can go negative - }); - if (updated.count === 0) { - return reply.code(404).send({ message: "Category not found" }); - } - } - } else { - // fixed_payment: Either a funding contribution (default) or a reconciliation payment - if (!planId) { - return reply.code(400).send({ message: "planId required" }); - } - const plan = await tx.fixedPlan.findFirst({ - where: { id: planId, userId }, - }); - if (!plan) return reply.code(404).send({ message: "Plan not found" }); - const userTimezone = - (await tx.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; - - const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); - const totalAmount = Number(plan.totalCents ?? 0n); - const isOneTime = !plan.frequency || plan.frequency === "one-time"; - const isReconciledPayment = !!isReconciled; - - if (!isReconciledPayment) { - const remainingNeeded = Math.max(0, totalAmount - fundedAmount); - const amountToFund = Math.min(amountCents, remainingNeeded); - - if (amountToFund <= 0) { - return reply.code(400).send({ message: "Plan is already fully funded." }); - } - - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - }); - - const availableBudget = categories.reduce( - (sum, cat) => sum + Number(cat.balanceCents ?? 0n), - 0 - ); - - if (availableBudget < amountToFund) { - const err: any = new Error("Insufficient available budget to fund this amount."); - err.statusCode = 400; - err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; - err.availableBudget = availableBudget; - err.shortage = amountToFund; - throw err; - } - - const shareResult = computeWithdrawShares(categories, amountToFund); - if (!shareResult.ok) { - const err: any = new Error( - shareResult.reason === "no_percent" - ? "No category percentages available." - : "Insufficient category balances to fund this amount." - ); - err.statusCode = 400; - err.code = - shareResult.reason === "no_percent" - ? "NO_CATEGORY_PERCENT" - : "INSUFFICIENT_CATEGORY_BALANCES"; - throw err; - } - - for (const s of shareResult.shares) { - if (s.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: s.id }, - data: { balanceCents: { decrement: BigInt(s.share) } }, - }); - } - - await tx.allocation.create({ - data: { - userId, - kind: "fixed", - toId: planId, - amountCents: BigInt(amountToFund), - incomeId: null, - }, - }); - - const newFunded = fundedAmount + amountToFund; - await tx.fixedPlan.update({ - where: { id: plan.id }, - data: { - fundedCents: BigInt(newFunded), - currentFundedCents: BigInt(newFunded), - lastFundingDate: new Date(), - lastFundedPayPeriod: new Date(), - needsFundingThisPeriod: newFunded < totalAmount, - }, - }); - - paidAmount = amountToFund; - - if (!isOneTime && newFunded >= totalAmount) { - if (plan.frequency && plan.frequency !== "one-time") { - updatedDueOn = calculateNextDueDate(plan.dueOn, plan.frequency, userTimezone); - } else { - updatedDueOn = plan.dueOn ?? undefined; - } - } - } else { - // Reconciliation: confirm a real payment - const normalizedPaid = Math.min(amountCents, totalAmount); - const shortage = Math.max(0, normalizedPaid - fundedAmount); - const effectiveFunded = fundedAmount + shortage; - - if (shortage > 0) { - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - }); - - const availableBudget = categories.reduce( - (sum, cat) => sum + Number(cat.balanceCents ?? 0n), - 0 - ); - - if (availableBudget < shortage) { - const err: any = new Error("Insufficient available budget to cover this payment."); - err.statusCode = 400; - err.code = "INSUFFICIENT_AVAILABLE_BUDGET"; - err.availableBudget = availableBudget; - err.shortage = shortage; - throw err; - } - - const shareResult = computeWithdrawShares(categories, shortage); - if (!shareResult.ok) { - const err: any = new Error( - shareResult.reason === "no_percent" - ? "No category percentages available." - : "Insufficient category balances to cover this payment." - ); - err.statusCode = 400; - err.code = - shareResult.reason === "no_percent" - ? "NO_CATEGORY_PERCENT" - : "INSUFFICIENT_CATEGORY_BALANCES"; - throw err; - } - - for (const s of shareResult.shares) { - if (s.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: s.id }, - data: { balanceCents: { decrement: BigInt(s.share) } }, - }); - } - - await tx.allocation.create({ - data: { - userId, - kind: "fixed", - toId: planId, - amountCents: BigInt(shortage), - incomeId: null, - }, - }); - } - - paidAmount = normalizedPaid; - - // Reconciliation logic based on payment amount vs funded amount - if (paidAmount >= totalAmount) { - if (isOneTime) { - deletePlanAfterPayment = true; - } else { - let frequency = plan.frequency; - if (!frequency && plan.paymentSchedule) { - const schedule = plan.paymentSchedule as any; - frequency = schedule.frequency; - } - if (frequency && frequency !== "one-time") { - updatedDueOn = calculateNextDueDate(plan.dueOn, frequency, userTimezone); - } else { - updatedDueOn = plan.dueOn ?? undefined; - } - - const updateData: any = { - fundedCents: 0n, - currentFundedCents: 0n, - isOverdue: false, - overdueAmount: 0n, - overdueSince: null, - needsFundingThisPeriod: plan.paymentSchedule ? true : false, - }; - if (updatedDueOn) { - updateData.dueOn = updatedDueOn; - updateData.nextPaymentDate = plan.autoPayEnabled - ? updatedDueOn - : null; - } - - await tx.fixedPlan.update({ - where: { id: plan.id }, - data: updateData, - }); - } - - } else if (paidAmount > 0 && paidAmount < totalAmount) { - const refundAmount = Math.max(0, effectiveFunded - paidAmount); - - if (refundAmount > 0) { - await tx.allocation.create({ - data: { - userId, - kind: "fixed", - toId: planId, - amountCents: BigInt(-refundAmount), - incomeId: null, - }, - }); - } - - const remainingBalance = totalAmount - paidAmount; - const updatedPlan = await tx.fixedPlan.update({ - where: { id: plan.id }, - data: { - fundedCents: 0n, - currentFundedCents: 0n, - isOverdue: true, - overdueAmount: BigInt(remainingBalance), - overdueSince: plan.overdueSince ?? new Date(), - needsFundingThisPeriod: true, - }, - select: { id: true, dueOn: true }, - }); - updatedDueOn = updatedPlan.dueOn ?? undefined; - - } else { - await tx.fixedPlan.update({ - where: { id: plan.id }, - data: { - isOverdue: true, - overdueAmount: BigInt(totalAmount - fundedAmount), - overdueSince: plan.overdueSince ?? new Date(), - needsFundingThisPeriod: true, - }, - }); - } - } - } - - const row = await tx.transaction.create({ - data: { - userId, - occurredAt: new Date(occurredAtISO), - kind, - amountCents: toBig(paidAmount), - categoryId: kind === "variable_spend" ? categoryId ?? null : null, - planId: kind === "fixed_payment" ? planId ?? null : null, - note: note?.trim() ? note.trim() : null, - receiptUrl: receiptUrl ?? null, - isReconciled: isReconciled ?? false, - isAutoPayment: false, - }, - select: { id: true, kind: true, amountCents: true, occurredAt: true }, - }); - - // If this was a fixed payment, include next due date info for UI toast - if (kind === "fixed_payment") { - if (deletePlanAfterPayment) { - await tx.fixedPlan.deleteMany({ where: { id: planId, userId } }); - } - return { - ...row, - planId, - nextDueOn: updatedDueOn || undefined, - } as any; - } - - return row; - }); -}); - // ----- Fixed Plans: Enable Early Funding ----- app.patch("/fixed-plans/:id/early-funding", mutationRateLimit, async (req, reply) => { const userId = req.userId; @@ -2419,314 +2006,6 @@ app.post("/fixed-plans/:id/catch-up-funding", mutationRateLimit, async (req, rep }); }); -// ----- Transactions: list ----- -app.get("/transactions", async (req, reply) => { - const Query = z.object({ - from: z.string().refine(isDate, "YYYY-MM-DD").optional(), - to: z.string().refine(isDate, "YYYY-MM-DD").optional(), - kind: z.enum(["variable_spend", "fixed_payment"]).optional(), - q: z.string().trim().optional(), - page: z.coerce.number().int().positive().default(1), - limit: z.coerce.number().int().positive().max(100).default(20), - bucketId: z.string().min(1).optional(), - categoryId: z.string().min(1).optional(), - sort: z.enum(["date", "amount", "kind", "bucket"]).optional(), - direction: z.enum(["asc", "desc"]).optional(), - }); - - const parsed = Query.safeParse(req.query); - if (!parsed.success) { - return reply - .code(400) - .send({ message: "Invalid query", issues: parsed.error.issues }); - } - - const { - from, - to, - kind, - q, - bucketId: rawBucketId, - categoryId, - sort = "date", - direction = "desc", - page, - limit, - } = parsed.data; - const bucketId = rawBucketId ?? categoryId; - const userId = req.userId; - const userTimezone = - (await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ?? - "America/New_York"; - - const where: Record = { userId }; - - if (from || to) { - where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to); - } - if (kind) { - where.kind = kind; - } else { - where.kind = { in: ["variable_spend", "fixed_payment"] }; - } - - const flexibleOr: any[] = []; - if (typeof q === "string" && q.trim() !== "") { - const qTrim = q.trim(); - const asCents = parseCurrencyToCents(qTrim); - if (asCents > 0) { - flexibleOr.push({ amountCents: toBig(asCents) }); - } - flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } }); - flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } }); - flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } }); - } - if (bucketId) { - if (!kind || kind === "variable_spend") { - flexibleOr.push({ categoryId: bucketId }); - } - if (!kind || kind === "fixed_payment") { - flexibleOr.push({ planId: bucketId }); - } - } - if (flexibleOr.length > 0) { - const existing = Array.isArray((where as any).OR) ? (where as any).OR : []; - (where as any).OR = [...existing, ...flexibleOr]; - } - - const skip = (page - 1) * limit; - const orderDirection = direction === "asc" ? "asc" : "desc"; - const orderBy = - sort === "amount" - ? [ - { amountCents: orderDirection as Prisma.SortOrder }, - { occurredAt: "desc" as Prisma.SortOrder }, - ] - : sort === "kind" - ? [ - { kind: orderDirection as Prisma.SortOrder }, - { occurredAt: "desc" as Prisma.SortOrder }, - ] - : sort === "bucket" - ? [ - { category: { name: orderDirection as Prisma.SortOrder } }, - { plan: { name: orderDirection as Prisma.SortOrder } }, - { occurredAt: "desc" as Prisma.SortOrder }, - ] - : [{ occurredAt: orderDirection as Prisma.SortOrder }]; - - const txInclude = Prisma.validator()({ - category: { select: { name: true } }, - plan: { select: { name: true } }, - }); - type TxWithRelations = Prisma.TransactionGetPayload<{ - include: typeof txInclude; - }>; - - const [total, itemsRaw] = await Promise.all([ - app.prisma.transaction.count({ where }), - app.prisma.transaction.findMany({ - where, - orderBy, - skip, - take: limit, - include: txInclude, - }) as Promise, - ]); - - const items = itemsRaw.map((tx) => ({ - id: tx.id, - kind: tx.kind, - amountCents: tx.amountCents, - occurredAt: tx.occurredAt, - categoryId: tx.categoryId, - categoryName: - tx.category?.name ?? - (tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null), - planId: tx.planId, - planName: tx.plan?.name ?? null, - note: tx.note ?? null, - receiptUrl: tx.receiptUrl ?? null, - isReconciled: !!tx.isReconciled, - isAutoPayment: !!tx.isAutoPayment, - })); - - return { items, page, limit, total }; -}); - -app.patch("/transactions/:id", mutationRateLimit, async (req, reply) => { - const Params = z.object({ id: z.string().min(1) }); - const Body = z.object({ - note: z - .string() - .trim() - .max(500) - .or(z.literal("")) - .optional(), - receiptUrl: z - .string() - .trim() - .max(2048) - .url() - .or(z.literal("")) - .optional(), - isReconciled: z.boolean().optional(), - }); - const params = Params.safeParse(req.params); - const parsed = Body.safeParse(req.body); - if (!params.success || !parsed.success) { - return reply.code(400).send({ message: "Invalid payload" }); - } - const userId = req.userId; - const id = params.data.id; - - if ( - parsed.data.note === undefined && - parsed.data.receiptUrl === undefined && - parsed.data.isReconciled === undefined - ) { - return reply.code(400).send({ message: "No fields to update" }); - } - - const existing = await app.prisma.transaction.findFirst({ where: { id, userId } }); - if (!existing) return reply.code(404).send({ message: "Transaction not found" }); - - const data: Prisma.TransactionUpdateInput = {}; - if (parsed.data.note !== undefined) { - const value = parsed.data.note.trim(); - data.note = value.length > 0 ? value : null; - } - if (parsed.data.receiptUrl !== undefined) { - const url = parsed.data.receiptUrl.trim(); - data.receiptUrl = url.length > 0 ? url : null; - } - if (parsed.data.isReconciled !== undefined) { - data.isReconciled = parsed.data.isReconciled; - } - - const updated = await app.prisma.transaction.updateMany({ - where: { id, userId }, - data, - }); - if (updated.count === 0) return reply.code(404).send({ message: "Transaction not found" }); - - const refreshed = await app.prisma.transaction.findFirst({ - where: { id, userId }, - select: { - id: true, - note: true, - receiptUrl: true, - isReconciled: true, - }, - }); - - return refreshed; -}); - -app.delete("/transactions/:id", mutationRateLimit, async (req, reply) => { - const Params = z.object({ id: z.string().min(1) }); - const params = Params.safeParse(req.params); - if (!params.success) { - return reply.code(400).send({ message: "Invalid transaction id" }); - } - - const userId = req.userId; - const id = params.data.id; - - return await app.prisma.$transaction(async (tx) => { - const existing = await tx.transaction.findFirst({ - where: { id, userId }, - }); - if (!existing) return reply.code(404).send({ message: "Transaction not found" }); - - const amountCents = Number(existing.amountCents ?? 0n); - if (existing.kind === "variable_spend") { - if (!existing.categoryId) { - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - }); - const shareResult = computeDepositShares(categories, amountCents); - if (!shareResult.ok) { - return reply.code(400).send({ message: "No category percentages available." }); - } - for (const s of shareResult.shares) { - if (s.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: s.id }, - data: { balanceCents: { increment: BigInt(s.share) } }, - }); - } - } else { - const updated = await tx.variableCategory.updateMany({ - where: { id: existing.categoryId, userId }, - data: { balanceCents: { increment: BigInt(amountCents) } }, - }); - if (updated.count === 0) { - return reply.code(404).send({ message: "Category not found" }); - } - } - } else if (existing.kind === "fixed_payment") { - if (!existing.planId) { - return reply.code(400).send({ message: "Transaction missing planId" }); - } - const plan = await tx.fixedPlan.findFirst({ - where: { id: existing.planId, userId }, - }); - if (!plan) { - return reply.code(404).send({ message: "Fixed plan not found" }); - } - - const categories = await tx.variableCategory.findMany({ - where: { userId }, - select: { id: true, percent: true, balanceCents: true }, - orderBy: [{ priority: "asc" }, { name: "asc" }], - }); - const shareResult = computeDepositShares(categories, amountCents); - if (!shareResult.ok) { - return reply.code(400).send({ message: "No category percentages available." }); - } - for (const s of shareResult.shares) { - if (s.share <= 0) continue; - await tx.variableCategory.update({ - where: { id: s.id }, - data: { balanceCents: { increment: BigInt(s.share) } }, - }); - } - await tx.allocation.create({ - data: { - userId, - kind: "fixed", - toId: existing.planId, - amountCents: BigInt(-amountCents), - incomeId: null, - }, - }); - - const fundedBefore = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n); - const total = Number(plan.totalCents ?? 0n); - const newFunded = Math.max(0, fundedBefore - amountCents); - const updatedPlan = await tx.fixedPlan.updateMany({ - where: { id: plan.id, userId }, - data: { - fundedCents: BigInt(newFunded), - currentFundedCents: BigInt(newFunded), - needsFundingThisPeriod: newFunded < total, - }, - }); - if (updatedPlan.count === 0) { - return reply.code(404).send({ message: "Fixed plan not found" }); - } - } - - const deleted = await tx.transaction.deleteMany({ where: { id, userId } }); - if (deleted.count === 0) return reply.code(404).send({ message: "Transaction not found" }); - - return { ok: true, id }; - }); -}); - // ----- Fixed plans ----- const PlanBody = z.object({ name: z.string().trim().min(1), diff --git a/docs/api-phase4-move-log.md b/docs/api-phase4-move-log.md new file mode 100644 index 0000000..776eff0 --- /dev/null +++ b/docs/api-phase4-move-log.md @@ -0,0 +1,57 @@ +# API Phase 4 Move Log + +Date: 2026-03-17 +Scope: Move `transactions` endpoints out of `api/src/server.ts` into a dedicated route module. + +## Route Registration Changes +- Added transactions route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:19) +- Registered transactions routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:938) +- New canonical route module: [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:38) +- Removed inline transactions route blocks from `server.ts` to avoid duplicate registration: + - `POST /transactions` block + - `GET/PATCH/DELETE /transactions` block + +## Endpoint Movements + +1. `POST /transactions` +- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:42) +- References: + - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:34) + - [payment-rollover.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/payment-rollover.test.ts:42) + - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:139) + +2. `GET /transactions` +- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:465) +- References: + - [useTransactionsQuery.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactionsQuery.tsx:41) + - [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/transactions.ts:27) + - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:71) + +3. `PATCH /transactions/:id` +- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:601) +- References: + - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:63) + - [transactions.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/transactions.test.ts:189) + +4. `DELETE /transactions/:id` +- Moved to [transactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/transactions.ts:659) +- References: + - [useTransactions.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useTransactions.ts:75) + - [SpendPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/SpendPage.tsx:296) + +## Helper Ownership in Phase 4 +- Shared helper injection from `server.ts`: + - `mutationRateLimit` + - `computeDepositShares` + - `computeWithdrawShares` + - `computeOverdraftShares` + - `calculateNextDueDate` + - `toBig` + - `parseCurrencyToCents` +- Route-local helper: + - `isDate` query validator + +## Follow-ups To Revisit +1. Split `POST /transactions` into smaller internal handlers (`variable_spend` vs `fixed_payment`) after endpoint migration phases complete. +2. Replace broad `any` shapes in transaction where-clause/order composition with a typed query builder to reduce regression risk. +3. Reconcile test expectations for plan overdraft behavior (`OVERDRAFT_PLAN` vs current fixed-payment reconciliation semantics) before behavior changes in later phases.