phase 4: simplify all transaction routes
This commit is contained in:
@@ -1,71 +1,764 @@
|
|||||||
// api/src/routes/transactions.ts
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getUserDateRangeFromDateOnly } from "../allocator.js";
|
import { getUserDateRangeFromDateOnly } from "../allocator.js";
|
||||||
|
|
||||||
|
type RateLimitRouteOptions = {
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: number;
|
||||||
|
timeWindow: number;
|
||||||
|
keyGenerator?: (req: any) => string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PercentCategory = {
|
||||||
|
id: string;
|
||||||
|
percent: number;
|
||||||
|
balanceCents: bigint | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareResult =
|
||||||
|
| { ok: true; shares: Array<{ id: string; share: number }> }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
type 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({
|
const Query = z.object({
|
||||||
from: z
|
from: z.string().refine(isDate, "YYYY-MM-DD").optional(),
|
||||||
.string()
|
to: z.string().refine(isDate, "YYYY-MM-DD").optional(),
|
||||||
.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(),
|
kind: z.enum(["variable_spend", "fixed_payment"]).optional(),
|
||||||
q: z.string().trim().optional(),
|
q: z.string().trim().optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
const parsed = Query.safeParse(req.query);
|
const parsed = Query.safeParse(req.query);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply
|
return reply.code(400).send({ message: "Invalid query", issues: parsed.error.issues });
|
||||||
.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) {
|
if (from || to) {
|
||||||
where.occurredAt = getUserDateRangeFromDateOnly(userTimezone, 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() !== "") {
|
if (typeof q === "string" && q.trim() !== "") {
|
||||||
const ors: any[] = [];
|
const qTrim = q.trim();
|
||||||
const asNumber = Number(q);
|
const asCents = opts.parseCurrencyToCents(qTrim);
|
||||||
if (Number.isFinite(asNumber)) {
|
if (asCents > 0) {
|
||||||
ors.push({ amountCents: BigInt(asNumber) });
|
flexibleOr.push({ amountCents: opts.toBig(asCents) });
|
||||||
}
|
}
|
||||||
if (ors.length > 0) {
|
flexibleOr.push({ note: { contains: qTrim, mode: "insensitive" } });
|
||||||
where.OR = ors;
|
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 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.count({ where }),
|
||||||
app.prisma.transaction.findMany({
|
(app.prisma.transaction.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { occurredAt: "desc" },
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
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 };
|
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;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import nodemailer from "nodemailer";
|
|||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
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 { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||||
import { rolloverFixedPlans } from "./jobs/rollover.js";
|
import { rolloverFixedPlans } from "./jobs/rollover.js";
|
||||||
import healthRoutes from "./routes/health.js";
|
import healthRoutes from "./routes/health.js";
|
||||||
@@ -16,6 +16,7 @@ import sessionRoutes from "./routes/session.js";
|
|||||||
import userRoutes from "./routes/user.js";
|
import userRoutes from "./routes/user.js";
|
||||||
import authAccountRoutes from "./routes/auth-account.js";
|
import authAccountRoutes from "./routes/auth-account.js";
|
||||||
import variableCategoriesRoutes from "./routes/variable-categories.js";
|
import variableCategoriesRoutes from "./routes/variable-categories.js";
|
||||||
|
import transactionsRoutes from "./routes/transactions.js";
|
||||||
|
|
||||||
export type AppConfig = typeof env;
|
export type AppConfig = typeof env;
|
||||||
|
|
||||||
@@ -165,7 +166,6 @@ const parseCurrencyToCents = (value: string): number => {
|
|||||||
const parsed = Number.parseFloat(normalized || "0");
|
const parsed = Number.parseFloat(normalized || "0");
|
||||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 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 addMonths = (date: Date, months: number) => {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setMonth(next.getMonth() + months);
|
next.setMonth(next.getMonth() + months);
|
||||||
@@ -935,6 +935,15 @@ await app.register(variableCategoriesRoutes, {
|
|||||||
mutationRateLimit,
|
mutationRateLimit,
|
||||||
computeDepositShares,
|
computeDepositShares,
|
||||||
});
|
});
|
||||||
|
await app.register(transactionsRoutes, {
|
||||||
|
mutationRateLimit,
|
||||||
|
computeDepositShares,
|
||||||
|
computeWithdrawShares,
|
||||||
|
computeOverdraftShares,
|
||||||
|
calculateNextDueDate,
|
||||||
|
toBig,
|
||||||
|
parseCurrencyToCents,
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/site-access/status", async (req) => {
|
app.get("/site-access/status", async (req) => {
|
||||||
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
if (!config.UNDER_CONSTRUCTION_ENABLED) {
|
||||||
@@ -1401,428 +1410,6 @@ app.post("/income", mutationRateLimit, async (req, reply) => {
|
|||||||
return result;
|
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 -----
|
// ----- Fixed Plans: Enable Early Funding -----
|
||||||
app.patch("/fixed-plans/:id/early-funding", mutationRateLimit, async (req, reply) => {
|
app.patch("/fixed-plans/:id/early-funding", mutationRateLimit, async (req, reply) => {
|
||||||
const userId = req.userId;
|
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 -----
|
// ----- Fixed plans -----
|
||||||
const PlanBody = z.object({
|
const PlanBody = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
|
|||||||
57
docs/api-phase4-move-log.md
Normal file
57
docs/api-phase4-move-log.md
Normal 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.
|
||||||
Reference in New Issue
Block a user