phase 4: simplify all transaction routes
All checks were successful
Deploy / deploy (push) Successful in 2m22s
Security Tests / security-non-db (push) Successful in 27s
Security Tests / security-db (push) Successful in 31s

This commit is contained in:
2026-03-17 09:00:48 -05:00
parent 4a63309153
commit 181c3bdc9e
3 changed files with 805 additions and 776 deletions

View File

@@ -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(),
type RateLimitRouteOptions = {
config: {
rateLimit: {
max: number;
timeWindow: number;
keyGenerator?: (req: any) => string;
};
};
};
type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | null;
};
type ShareResult =
| { ok: true; shares: Array<{ id: string; share: number }> }
| { ok: false; reason: string };
type 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<TransactionsRoutesOptions> = 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 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),
});
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" });
}
const userId = req.userId;
const userTimezone =
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
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<string, unknown> = { 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) });
const qTrim = q.trim();
const asCents = opts.parseCurrencyToCents(qTrim);
if (asCents > 0) {
flexibleOr.push({ amountCents: opts.toBig(asCents) });
}
if (ors.length > 0) {
where.OR = ors;
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<Prisma.TransactionInclude>()({
category: { select: { name: true } },
plan: { select: { name: true } },
});
type TxWithRelations = Prisma.TransactionGetPayload<{
include: typeof txInclude;
}>;
const [total, itemsRaw] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
(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<TxWithRelations[]>),
]);
const items = itemsRaw.map((tx) => ({
id: tx.id,
kind: tx.kind,
amountCents: tx.amountCents,
occurredAt: tx.occurredAt,
categoryId: tx.categoryId,
categoryName:
tx.category?.name ??
(tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null),
planId: tx.planId,
planName: tx.plan?.name ?? null,
note: tx.note ?? null,
receiptUrl: tx.receiptUrl ?? null,
isReconciled: !!tx.isReconciled,
isAutoPayment: !!tx.isAutoPayment,
}));
return { items, page, limit, total };
});
});
app.patch("/transactions/:id", 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;

View File

@@ -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<string, unknown> = { userId };
if (from || to) {
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, from, to);
}
if (kind) {
where.kind = kind;
} else {
where.kind = { in: ["variable_spend", "fixed_payment"] };
}
const flexibleOr: any[] = [];
if (typeof q === "string" && q.trim() !== "") {
const qTrim = q.trim();
const asCents = parseCurrencyToCents(qTrim);
if (asCents > 0) {
flexibleOr.push({ amountCents: toBig(asCents) });
}
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
flexibleOr.push({ category: { name: { contains: qTrim, mode: "insensitive" } } });
flexibleOr.push({ plan: { name: { contains: qTrim, mode: "insensitive" } } });
}
if (bucketId) {
if (!kind || kind === "variable_spend") {
flexibleOr.push({ categoryId: bucketId });
}
if (!kind || kind === "fixed_payment") {
flexibleOr.push({ planId: bucketId });
}
}
if (flexibleOr.length > 0) {
const existing = Array.isArray((where as any).OR) ? (where as any).OR : [];
(where as any).OR = [...existing, ...flexibleOr];
}
const skip = (page - 1) * limit;
const orderDirection = direction === "asc" ? "asc" : "desc";
const orderBy =
sort === "amount"
? [
{ amountCents: orderDirection as Prisma.SortOrder },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: sort === "kind"
? [
{ kind: orderDirection as Prisma.SortOrder },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: sort === "bucket"
? [
{ category: { name: orderDirection as Prisma.SortOrder } },
{ plan: { name: orderDirection as Prisma.SortOrder } },
{ occurredAt: "desc" as Prisma.SortOrder },
]
: [{ occurredAt: orderDirection as Prisma.SortOrder }];
const txInclude = Prisma.validator<Prisma.TransactionInclude>()({
category: { select: { name: true } },
plan: { select: { name: true } },
});
type TxWithRelations = Prisma.TransactionGetPayload<{
include: typeof txInclude;
}>;
const [total, itemsRaw] = await Promise.all([
app.prisma.transaction.count({ where }),
app.prisma.transaction.findMany({
where,
orderBy,
skip,
take: limit,
include: txInclude,
}) as Promise<TxWithRelations[]>,
]);
const items = itemsRaw.map((tx) => ({
id: tx.id,
kind: tx.kind,
amountCents: tx.amountCents,
occurredAt: tx.occurredAt,
categoryId: tx.categoryId,
categoryName:
tx.category?.name ??
(tx.kind === "variable_spend" && !tx.categoryId ? "Other" : null),
planId: tx.planId,
planName: tx.plan?.name ?? null,
note: tx.note ?? null,
receiptUrl: tx.receiptUrl ?? null,
isReconciled: !!tx.isReconciled,
isAutoPayment: !!tx.isAutoPayment,
}));
return { items, page, limit, total };
});
app.patch("/transactions/:id", mutationRateLimit, async (req, reply) => {
const Params = z.object({ id: z.string().min(1) });
const Body = z.object({
note: z
.string()
.trim()
.max(500)
.or(z.literal(""))
.optional(),
receiptUrl: z
.string()
.trim()
.max(2048)
.url()
.or(z.literal(""))
.optional(),
isReconciled: z.boolean().optional(),
});
const params = Params.safeParse(req.params);
const parsed = Body.safeParse(req.body);
if (!params.success || !parsed.success) {
return reply.code(400).send({ message: "Invalid payload" });
}
const userId = req.userId;
const id = params.data.id;
if (
parsed.data.note === undefined &&
parsed.data.receiptUrl === undefined &&
parsed.data.isReconciled === undefined
) {
return reply.code(400).send({ message: "No fields to update" });
}
const existing = await app.prisma.transaction.findFirst({ where: { id, userId } });
if (!existing) return reply.code(404).send({ message: "Transaction not found" });
const data: Prisma.TransactionUpdateInput = {};
if (parsed.data.note !== undefined) {
const value = parsed.data.note.trim();
data.note = value.length > 0 ? value : null;
}
if (parsed.data.receiptUrl !== undefined) {
const url = parsed.data.receiptUrl.trim();
data.receiptUrl = url.length > 0 ? url : null;
}
if (parsed.data.isReconciled !== undefined) {
data.isReconciled = parsed.data.isReconciled;
}
const updated = await app.prisma.transaction.updateMany({
where: { id, userId },
data,
});
if (updated.count === 0) return reply.code(404).send({ message: "Transaction not found" });
const refreshed = await app.prisma.transaction.findFirst({
where: { id, userId },
select: {
id: true,
note: true,
receiptUrl: true,
isReconciled: true,
},
});
return refreshed;
});
app.delete("/transactions/:id", mutationRateLimit, async (req, reply) => {
const Params = z.object({ id: z.string().min(1) });
const params = Params.safeParse(req.params);
if (!params.success) {
return reply.code(400).send({ message: "Invalid transaction id" });
}
const userId = req.userId;
const id = params.data.id;
return await app.prisma.$transaction(async (tx) => {
const existing = await tx.transaction.findFirst({
where: { id, userId },
});
if (!existing) return reply.code(404).send({ message: "Transaction not found" });
const amountCents = Number(existing.amountCents ?? 0n);
if (existing.kind === "variable_spend") {
if (!existing.categoryId) {
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const shareResult = computeDepositShares(categories, amountCents);
if (!shareResult.ok) {
return reply.code(400).send({ message: "No category percentages available." });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { increment: BigInt(s.share) } },
});
}
} else {
const updated = await tx.variableCategory.updateMany({
where: { id: existing.categoryId, userId },
data: { balanceCents: { increment: BigInt(amountCents) } },
});
if (updated.count === 0) {
return reply.code(404).send({ message: "Category not found" });
}
}
} else if (existing.kind === "fixed_payment") {
if (!existing.planId) {
return reply.code(400).send({ message: "Transaction missing planId" });
}
const plan = await tx.fixedPlan.findFirst({
where: { id: existing.planId, userId },
});
if (!plan) {
return reply.code(404).send({ message: "Fixed plan not found" });
}
const categories = await tx.variableCategory.findMany({
where: { userId },
select: { id: true, percent: true, balanceCents: true },
orderBy: [{ priority: "asc" }, { name: "asc" }],
});
const shareResult = computeDepositShares(categories, amountCents);
if (!shareResult.ok) {
return reply.code(400).send({ message: "No category percentages available." });
}
for (const s of shareResult.shares) {
if (s.share <= 0) continue;
await tx.variableCategory.update({
where: { id: s.id },
data: { balanceCents: { increment: BigInt(s.share) } },
});
}
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: existing.planId,
amountCents: BigInt(-amountCents),
incomeId: null,
},
});
const fundedBefore = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const newFunded = Math.max(0, fundedBefore - amountCents);
const updatedPlan = await tx.fixedPlan.updateMany({
where: { id: plan.id, userId },
data: {
fundedCents: BigInt(newFunded),
currentFundedCents: BigInt(newFunded),
needsFundingThisPeriod: newFunded < total,
},
});
if (updatedPlan.count === 0) {
return reply.code(404).send({ message: "Fixed plan not found" });
}
}
const deleted = await tx.transaction.deleteMany({ where: { id, userId } });
if (deleted.count === 0) return reply.code(404).send({ message: "Transaction not found" });
return { ok: true, id };
});
});
// ----- Fixed plans -----
const PlanBody = z.object({
name: z.string().trim().min(1),

View File

@@ -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.