phase 4: simplify all transaction routes
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user