1316 lines
43 KiB
TypeScript
1316 lines
43 KiB
TypeScript
import type { FastifyPluginAsync } from "fastify";
|
|
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import {
|
|
countPayPeriodsBetween,
|
|
getUserMidnight,
|
|
getUserMidnightFromDateOnly,
|
|
} from "../allocator.js";
|
|
import { getUserTimezone } from "../services/user-context.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 FixedPlansRoutesOptions = {
|
|
mutationRateLimit: RateLimitRouteOptions;
|
|
computeDepositShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
|
|
computeWithdrawShares: (categories: PercentCategory[], amountCents: number) => ShareResult;
|
|
calculateNextDueDate: (currentDueDate: Date, frequency: string, timezone?: string) => Date;
|
|
toBig: (n: number | string | bigint) => bigint;
|
|
};
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const PlanBody = z.object({
|
|
name: z.string().trim().min(1),
|
|
totalCents: z.number().int().min(0),
|
|
fundedCents: z.number().int().min(0).optional(),
|
|
amountMode: z.enum(["fixed", "estimated"]).optional(),
|
|
estimatedCents: z.number().int().min(0).optional(),
|
|
priority: z.number().int().min(0),
|
|
dueOn: z.string().datetime(),
|
|
cycleStart: z.string().datetime().optional(),
|
|
frequency: z.enum(["one-time", "weekly", "biweekly", "monthly"]).optional(),
|
|
autoPayEnabled: z.boolean().optional(),
|
|
paymentSchedule: z
|
|
.object({
|
|
frequency: z.enum(["daily", "weekly", "biweekly", "monthly", "custom"]),
|
|
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
|
dayOfWeek: z.number().int().min(0).max(6).optional(),
|
|
everyNDays: z.number().int().min(1).max(365).optional(),
|
|
minFundingPercent: z.number().min(0).max(100).default(100),
|
|
})
|
|
.partial({ dayOfMonth: true, dayOfWeek: true, everyNDays: true })
|
|
.optional(),
|
|
nextPaymentDate: z.string().datetime().optional(),
|
|
maxRetryAttempts: z.number().int().min(0).max(10).optional(),
|
|
});
|
|
|
|
const PlanAmountMode = z.enum(["fixed", "estimated"]);
|
|
|
|
const fixedPlansRoutes: FastifyPluginAsync<FixedPlansRoutesOptions> = async (
|
|
app,
|
|
opts
|
|
) => {
|
|
app.patch("/fixed-plans/:id/early-funding", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
if (!params.success) {
|
|
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
|
|
}
|
|
const planId = params.data.id;
|
|
const Body = z.object({
|
|
enableEarlyFunding: z.boolean(),
|
|
});
|
|
const parsed = Body.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid request", issues: parsed.error.issues });
|
|
}
|
|
|
|
const plan = await app.prisma.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
});
|
|
|
|
if (!plan) {
|
|
return reply.code(404).send({ message: "Plan not found" });
|
|
}
|
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
|
|
|
await app.prisma.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: parsed.data.enableEarlyFunding
|
|
? (() => {
|
|
let nextDue = plan.dueOn;
|
|
let frequency = plan.frequency;
|
|
if (!frequency && plan.paymentSchedule) {
|
|
const schedule = plan.paymentSchedule as any;
|
|
frequency = schedule.frequency;
|
|
}
|
|
if (frequency && frequency !== "one-time") {
|
|
nextDue = opts.calculateNextDueDate(plan.dueOn, frequency as any, userTimezone);
|
|
}
|
|
return {
|
|
fundedCents: 0n,
|
|
currentFundedCents: 0n,
|
|
needsFundingThisPeriod: true,
|
|
cycleStart: getUserMidnight(userTimezone, new Date()),
|
|
dueOn: nextDue,
|
|
lastRollover: new Date(),
|
|
};
|
|
})()
|
|
: {
|
|
needsFundingThisPeriod: false,
|
|
},
|
|
});
|
|
|
|
return reply.send({
|
|
ok: true,
|
|
planId,
|
|
needsFundingThisPeriod: parsed.data.enableEarlyFunding,
|
|
});
|
|
});
|
|
|
|
app.post("/fixed-plans/:id/attempt-final-funding", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
if (!params.success) {
|
|
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
|
|
}
|
|
const planId = params.data.id;
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const plan = await tx.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
});
|
|
|
|
if (!plan) {
|
|
return reply.code(404).send({ message: "Plan not found" });
|
|
}
|
|
|
|
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
const totalAmount = Number(plan.totalCents ?? 0n);
|
|
const remainingNeeded = totalAmount - fundedAmount;
|
|
|
|
if (remainingNeeded <= 0) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
status: "fully_funded",
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
isOverdue: false,
|
|
};
|
|
}
|
|
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
const availableBudget = categories.reduce(
|
|
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
|
|
0
|
|
);
|
|
|
|
if (availableBudget >= remainingNeeded) {
|
|
const shareResult = opts.computeWithdrawShares(categories, remainingNeeded);
|
|
if (!shareResult.ok) {
|
|
return reply.code(400).send({ message: "Insufficient category balances" });
|
|
}
|
|
|
|
for (const s of shareResult.shares) {
|
|
if (s.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: { decrement: BigInt(s.share) } },
|
|
});
|
|
}
|
|
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(remainingNeeded),
|
|
},
|
|
});
|
|
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
currentFundedCents: BigInt(totalAmount),
|
|
fundedCents: BigInt(totalAmount),
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
status: "fully_funded",
|
|
fundedCents: totalAmount,
|
|
totalCents: totalAmount,
|
|
isOverdue: false,
|
|
message: `Topped off with $${(remainingNeeded / 100).toFixed(2)} from available budget`,
|
|
};
|
|
} else if (availableBudget > 0) {
|
|
const shareResult = opts.computeWithdrawShares(categories, availableBudget);
|
|
if (!shareResult.ok) {
|
|
return reply.code(400).send({ message: "Insufficient category balances" });
|
|
}
|
|
|
|
for (const s of shareResult.shares) {
|
|
if (s.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: { decrement: BigInt(s.share) } },
|
|
});
|
|
}
|
|
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(availableBudget),
|
|
},
|
|
});
|
|
|
|
const newFundedAmount = fundedAmount + availableBudget;
|
|
const overdueAmount = totalAmount - newFundedAmount;
|
|
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
currentFundedCents: BigInt(newFundedAmount),
|
|
fundedCents: BigInt(newFundedAmount),
|
|
isOverdue: true,
|
|
overdueAmount: BigInt(overdueAmount),
|
|
overdueSince: plan.overdueSince ?? new Date(),
|
|
needsFundingThisPeriod: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
status: "overdue",
|
|
fundedCents: newFundedAmount,
|
|
totalCents: totalAmount,
|
|
isOverdue: true,
|
|
overdueAmount,
|
|
message: `Used all available budget ($${(availableBudget / 100).toFixed(2)}). Remaining $${(overdueAmount / 100).toFixed(2)} marked overdue.`,
|
|
};
|
|
} else {
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
isOverdue: true,
|
|
overdueAmount: BigInt(remainingNeeded),
|
|
overdueSince: plan.overdueSince ?? new Date(),
|
|
needsFundingThisPeriod: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
status: "overdue",
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
isOverdue: true,
|
|
overdueAmount: remainingNeeded,
|
|
message: `No available budget. $${(remainingNeeded / 100).toFixed(2)} marked overdue.`,
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
app.patch("/fixed-plans/:id/mark-unpaid", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
if (!params.success) {
|
|
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
|
|
}
|
|
const planId = params.data.id;
|
|
|
|
const plan = await app.prisma.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
});
|
|
|
|
if (!plan) {
|
|
return reply.code(404).send({ message: "Plan not found" });
|
|
}
|
|
|
|
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
const totalAmount = Number(plan.totalCents ?? 0n);
|
|
const remainingBalance = totalAmount - fundedAmount;
|
|
|
|
await app.prisma.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
isOverdue: true,
|
|
overdueAmount: BigInt(Math.max(0, remainingBalance)),
|
|
overdueSince: plan.overdueSince ?? new Date(),
|
|
needsFundingThisPeriod: true,
|
|
},
|
|
});
|
|
|
|
return reply.send({
|
|
ok: true,
|
|
planId,
|
|
isOverdue: true,
|
|
overdueAmount: Math.max(0, remainingBalance),
|
|
});
|
|
});
|
|
|
|
app.post("/fixed-plans/:id/fund-from-available", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
if (!params.success) {
|
|
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
|
|
}
|
|
const planId = params.data.id;
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const plan = await tx.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
});
|
|
|
|
if (!plan) {
|
|
return reply.code(404).send({ message: "Plan not found" });
|
|
}
|
|
|
|
const user = await tx.user.findUnique({
|
|
where: { id: userId },
|
|
select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true },
|
|
});
|
|
|
|
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
const totalAmount = Number(plan.totalCents ?? 0n);
|
|
const remainingNeeded = Math.max(0, totalAmount - fundedAmount);
|
|
|
|
if (remainingNeeded <= 0) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: true,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
message: "Already fully funded",
|
|
};
|
|
}
|
|
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
const availableBudget = categories.reduce(
|
|
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
|
|
0
|
|
);
|
|
|
|
let amountToFund = remainingNeeded;
|
|
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
|
|
if (user?.incomeType === "regular" && hasPaymentSchedule) {
|
|
const timezone = user?.timezone || "America/New_York";
|
|
const now = new Date();
|
|
const userNow = getUserMidnight(timezone, now);
|
|
const userDueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn);
|
|
let cyclesLeft = 1;
|
|
if (user?.firstIncomeDate && user?.incomeFrequency) {
|
|
cyclesLeft = countPayPeriodsBetween(
|
|
userNow,
|
|
userDueDate,
|
|
user.firstIncomeDate,
|
|
user.incomeFrequency,
|
|
timezone
|
|
);
|
|
} else if (user?.incomeFrequency) {
|
|
const freqDays =
|
|
user.incomeFrequency === "weekly"
|
|
? 7
|
|
: user.incomeFrequency === "biweekly"
|
|
? 14
|
|
: 30;
|
|
const daysUntilDue = Math.max(
|
|
0,
|
|
Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS)
|
|
);
|
|
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
|
|
}
|
|
amountToFund = Math.min(remainingNeeded, Math.ceil(remainingNeeded / cyclesLeft));
|
|
}
|
|
|
|
if (availableBudget < amountToFund) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message: "Insufficient available budget",
|
|
};
|
|
}
|
|
|
|
const shareResult = opts.computeWithdrawShares(categories, amountToFund);
|
|
if (!shareResult.ok) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message:
|
|
shareResult.reason === "no_percent"
|
|
? "No category percentages available"
|
|
: "Insufficient category balances",
|
|
};
|
|
}
|
|
|
|
for (const s of shareResult.shares) {
|
|
if (s.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: { decrement: BigInt(s.share) } },
|
|
});
|
|
}
|
|
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(amountToFund),
|
|
},
|
|
});
|
|
|
|
const newFunded = fundedAmount + amountToFund;
|
|
const stillNeedsFunding = newFunded < totalAmount;
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
fundedCents: BigInt(newFunded),
|
|
currentFundedCents: BigInt(newFunded),
|
|
lastFundingDate: new Date(),
|
|
needsFundingThisPeriod: stillNeedsFunding,
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: true,
|
|
fundedAmountCents: amountToFund,
|
|
fundedCents: newFunded,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message: "Funded from available budget",
|
|
};
|
|
});
|
|
});
|
|
|
|
app.post("/fixed-plans/:id/catch-up-funding", opts.mutationRateLimit, async (req, reply) => {
|
|
const userId = req.userId;
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
if (!params.success) {
|
|
return reply.code(400).send({ message: "Invalid plan id", issues: params.error.issues });
|
|
}
|
|
const planId = params.data.id;
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const plan = await tx.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
});
|
|
|
|
if (!plan) {
|
|
return reply.code(404).send({ message: "Plan not found" });
|
|
}
|
|
|
|
const user = await tx.user.findUnique({
|
|
where: { id: userId },
|
|
select: { incomeType: true, incomeFrequency: true, firstIncomeDate: true, timezone: true },
|
|
});
|
|
|
|
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
|
|
if (!hasPaymentSchedule || user?.incomeType !== "regular") {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n),
|
|
totalCents: Number(plan.totalCents ?? 0n),
|
|
message: "No payment plan to catch up",
|
|
};
|
|
}
|
|
|
|
const fundedAmount = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
const totalAmount = Number(plan.totalCents ?? 0n);
|
|
if (totalAmount <= 0) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
message: "No amount to fund",
|
|
};
|
|
}
|
|
|
|
const timezone = user?.timezone || "America/New_York";
|
|
const now = new Date();
|
|
let cycleStart = getUserMidnightFromDateOnly(timezone, plan.cycleStart);
|
|
const dueDate = getUserMidnightFromDateOnly(timezone, plan.dueOn);
|
|
const userNow = getUserMidnight(timezone, now);
|
|
|
|
if (cycleStart >= dueDate || cycleStart > userNow) {
|
|
cycleStart = userNow;
|
|
}
|
|
|
|
let totalPeriods = 1;
|
|
let elapsedPeriods = 1;
|
|
if (user?.firstIncomeDate && user?.incomeFrequency) {
|
|
totalPeriods = countPayPeriodsBetween(
|
|
cycleStart,
|
|
dueDate,
|
|
user.firstIncomeDate,
|
|
user.incomeFrequency,
|
|
timezone
|
|
);
|
|
elapsedPeriods = countPayPeriodsBetween(
|
|
cycleStart,
|
|
userNow,
|
|
user.firstIncomeDate,
|
|
user.incomeFrequency,
|
|
timezone
|
|
);
|
|
}
|
|
|
|
totalPeriods = Math.max(1, totalPeriods);
|
|
elapsedPeriods = Math.max(1, Math.min(elapsedPeriods, totalPeriods));
|
|
|
|
const targetFunded = Math.min(
|
|
totalAmount,
|
|
Math.ceil((totalAmount * elapsedPeriods) / totalPeriods)
|
|
);
|
|
const needed = Math.max(0, targetFunded - fundedAmount);
|
|
|
|
if (needed === 0) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
message: "No catch-up needed",
|
|
};
|
|
}
|
|
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
const availableBudget = categories.reduce(
|
|
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
|
|
0
|
|
);
|
|
|
|
if (availableBudget < needed) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message: "Insufficient available budget",
|
|
};
|
|
}
|
|
|
|
const shareResult = opts.computeWithdrawShares(categories, needed);
|
|
if (!shareResult.ok) {
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: false,
|
|
fundedAmountCents: 0,
|
|
fundedCents: fundedAmount,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message:
|
|
shareResult.reason === "no_percent"
|
|
? "No category percentages available"
|
|
: "Insufficient category balances",
|
|
};
|
|
}
|
|
|
|
for (const s of shareResult.shares) {
|
|
if (s.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: { decrement: BigInt(s.share) } },
|
|
});
|
|
}
|
|
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(needed),
|
|
},
|
|
});
|
|
|
|
const newFunded = fundedAmount + needed;
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
fundedCents: BigInt(newFunded),
|
|
currentFundedCents: BigInt(newFunded),
|
|
lastFundingDate: new Date(),
|
|
needsFundingThisPeriod: newFunded < totalAmount,
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
funded: true,
|
|
fundedAmountCents: needed,
|
|
fundedCents: newFunded,
|
|
totalCents: totalAmount,
|
|
availableBudget,
|
|
message: "Catch-up funded from available budget",
|
|
};
|
|
});
|
|
});
|
|
|
|
app.post("/fixed-plans", opts.mutationRateLimit, async (req, reply) => {
|
|
const parsed = PlanBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid payload" });
|
|
}
|
|
const userId = req.userId;
|
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
|
|
|
const amountMode = parsed.data.amountMode ?? "fixed";
|
|
if (amountMode === "estimated" && parsed.data.estimatedCents === undefined) {
|
|
return reply.code(400).send({
|
|
code: "ESTIMATED_CENTS_REQUIRED",
|
|
message: "estimatedCents is required when amountMode is estimated",
|
|
});
|
|
}
|
|
const computedTotalCents =
|
|
amountMode === "estimated"
|
|
? parsed.data.totalCents ?? parsed.data.estimatedCents ?? 0
|
|
: parsed.data.totalCents;
|
|
const totalBig = opts.toBig(computedTotalCents);
|
|
const fundedBig = opts.toBig(parsed.data.fundedCents ?? 0);
|
|
if (fundedBig > totalBig) {
|
|
return reply
|
|
.code(400)
|
|
.send({ message: "fundedCents cannot exceed totalCents" });
|
|
}
|
|
const autoPayEnabled = !!parsed.data.autoPayEnabled && !!parsed.data.paymentSchedule;
|
|
const paymentSchedule = parsed.data.paymentSchedule
|
|
? { ...parsed.data.paymentSchedule, minFundingPercent: parsed.data.paymentSchedule.minFundingPercent ?? 100 }
|
|
: null;
|
|
const nextPaymentDate =
|
|
parsed.data.nextPaymentDate && autoPayEnabled
|
|
? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.nextPaymentDate))
|
|
: autoPayEnabled && parsed.data.dueOn
|
|
? getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn))
|
|
: null;
|
|
|
|
let frequency = parsed.data.frequency;
|
|
if (!frequency && paymentSchedule?.frequency) {
|
|
const scheduleFreq = paymentSchedule.frequency;
|
|
if (scheduleFreq === "monthly" || scheduleFreq === "weekly" || scheduleFreq === "biweekly") {
|
|
frequency = scheduleFreq;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const created = await app.prisma.fixedPlan.create({
|
|
data: {
|
|
userId,
|
|
name: parsed.data.name,
|
|
amountMode,
|
|
estimatedCents:
|
|
amountMode === "estimated" && parsed.data.estimatedCents !== undefined
|
|
? opts.toBig(parsed.data.estimatedCents)
|
|
: null,
|
|
actualCents: null,
|
|
actualCycleDueOn: null,
|
|
actualRecordedAt: null,
|
|
totalCents: totalBig,
|
|
fundedCents: fundedBig,
|
|
currentFundedCents: fundedBig,
|
|
priority: parsed.data.priority,
|
|
dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.dueOn)),
|
|
cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(parsed.data.cycleStart ?? parsed.data.dueOn)),
|
|
frequency: frequency || null,
|
|
fundingMode: "auto-on-deposit",
|
|
autoPayEnabled,
|
|
paymentSchedule: paymentSchedule ?? Prisma.DbNull,
|
|
nextPaymentDate: autoPayEnabled ? nextPaymentDate : null,
|
|
maxRetryAttempts: parsed.data.maxRetryAttempts ?? 3,
|
|
lastFundingDate: fundedBig > 0n ? new Date() : null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return reply.code(201).send(created);
|
|
} catch (error: any) {
|
|
if (error.code === "P2002") {
|
|
return reply.code(400).send({
|
|
error: "DUPLICATE_NAME",
|
|
message: `Fixed plan name '${parsed.data.name}' already exists`,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
app.patch("/fixed-plans/:id", opts.mutationRateLimit, async (req, reply) => {
|
|
const patch = PlanBody.partial().safeParse(req.body);
|
|
if (!patch.success) {
|
|
return reply.code(400).send({ message: "Invalid payload" });
|
|
}
|
|
const id = String((req.params as any).id);
|
|
const userId = req.userId;
|
|
const userTimezone = await getUserTimezone(app.prisma, userId);
|
|
|
|
const plan = await app.prisma.fixedPlan.findFirst({
|
|
where: { id, userId },
|
|
});
|
|
if (!plan) return reply.code(404).send({ message: "Not found" });
|
|
|
|
const total =
|
|
"totalCents" in patch.data
|
|
? opts.toBig(patch.data.totalCents as number)
|
|
: plan.totalCents ?? 0n;
|
|
const funded =
|
|
"fundedCents" in patch.data
|
|
? opts.toBig(patch.data.fundedCents as number)
|
|
: plan.fundedCents ?? 0n;
|
|
if (funded > total) {
|
|
return reply
|
|
.code(400)
|
|
.send({ message: "fundedCents cannot exceed totalCents" });
|
|
}
|
|
const amountMode =
|
|
patch.data.amountMode !== undefined
|
|
? PlanAmountMode.parse(patch.data.amountMode)
|
|
: ((plan.amountMode as "fixed" | "estimated" | null) ?? "fixed");
|
|
if (
|
|
amountMode === "estimated" &&
|
|
patch.data.estimatedCents === undefined &&
|
|
plan.estimatedCents === null
|
|
) {
|
|
return reply.code(400).send({
|
|
code: "ESTIMATED_CENTS_REQUIRED",
|
|
message: "estimatedCents is required when amountMode is estimated",
|
|
});
|
|
}
|
|
const nextEstimatedCents =
|
|
patch.data.estimatedCents !== undefined
|
|
? opts.toBig(patch.data.estimatedCents)
|
|
: plan.estimatedCents;
|
|
const hasActualForCycle =
|
|
plan.actualCycleDueOn && plan.actualCycleDueOn.getTime() === plan.dueOn.getTime();
|
|
const updateTotalFromEstimate =
|
|
amountMode === "estimated" &&
|
|
patch.data.estimatedCents !== undefined &&
|
|
!hasActualForCycle &&
|
|
patch.data.totalCents === undefined;
|
|
const nextTotal =
|
|
updateTotalFromEstimate
|
|
? opts.toBig(patch.data.estimatedCents as number)
|
|
: total;
|
|
if (funded > nextTotal) {
|
|
return reply
|
|
.code(400)
|
|
.send({ message: "fundedCents cannot exceed totalCents" });
|
|
}
|
|
|
|
const hasScheduleInPatch = "paymentSchedule" in patch.data;
|
|
const paymentSchedule =
|
|
hasScheduleInPatch && patch.data.paymentSchedule
|
|
? { ...patch.data.paymentSchedule, minFundingPercent: patch.data.paymentSchedule.minFundingPercent ?? 100 }
|
|
: hasScheduleInPatch
|
|
? null
|
|
: undefined;
|
|
const autoPayEnabled =
|
|
"autoPayEnabled" in patch.data
|
|
? !!patch.data.autoPayEnabled &&
|
|
paymentSchedule !== null &&
|
|
(paymentSchedule !== undefined ? true : !!plan.paymentSchedule)
|
|
: paymentSchedule === null
|
|
? false
|
|
: plan.autoPayEnabled;
|
|
const nextPaymentDate =
|
|
"nextPaymentDate" in patch.data
|
|
? patch.data.nextPaymentDate
|
|
? getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.nextPaymentDate))
|
|
: null
|
|
: undefined;
|
|
|
|
const updated = await app.prisma.fixedPlan.updateMany({
|
|
where: { id, userId },
|
|
data: {
|
|
...patch.data,
|
|
amountMode,
|
|
estimatedCents:
|
|
amountMode === "estimated"
|
|
? (nextEstimatedCents ?? null)
|
|
: null,
|
|
...(patch.data.totalCents !== undefined || updateTotalFromEstimate ? { totalCents: nextTotal } : {}),
|
|
...(patch.data.fundedCents !== undefined
|
|
? { fundedCents: funded, currentFundedCents: funded, lastFundingDate: new Date() }
|
|
: {}),
|
|
...(patch.data.dueOn ? { dueOn: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.dueOn)) } : {}),
|
|
...(patch.data.cycleStart
|
|
? { cycleStart: getUserMidnightFromDateOnly(userTimezone, new Date(patch.data.cycleStart)) }
|
|
: {}),
|
|
...(paymentSchedule !== undefined
|
|
? { paymentSchedule: paymentSchedule ?? Prisma.DbNull }
|
|
: {}),
|
|
...(autoPayEnabled !== undefined ? { autoPayEnabled } : {}),
|
|
...(nextPaymentDate !== undefined
|
|
? { nextPaymentDate: autoPayEnabled ? nextPaymentDate : null }
|
|
: {}),
|
|
...(patch.data.maxRetryAttempts !== undefined
|
|
? { maxRetryAttempts: patch.data.maxRetryAttempts }
|
|
: {}),
|
|
},
|
|
});
|
|
if (updated.count === 0) return reply.code(404).send({ message: "Not found" });
|
|
return { ok: true };
|
|
});
|
|
|
|
app.delete("/fixed-plans/:id", opts.mutationRateLimit, async (req, reply) => {
|
|
const id = String((req.params as any).id);
|
|
const userId = req.userId;
|
|
const plan = await app.prisma.fixedPlan.findFirst({
|
|
where: { id, userId },
|
|
select: { id: true, fundedCents: true, currentFundedCents: true },
|
|
});
|
|
if (!plan) return reply.code(404).send({ message: "Not found" });
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const refundCents = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
if (refundCents > 0) {
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
|
|
const shareResult = opts.computeDepositShares(categories, refundCents);
|
|
if (shareResult.ok) {
|
|
for (const s of shareResult.shares) {
|
|
if (s.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: s.id },
|
|
data: { balanceCents: { increment: BigInt(s.share) } },
|
|
});
|
|
}
|
|
}
|
|
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: plan.id,
|
|
amountCents: BigInt(-refundCents),
|
|
incomeId: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.fixedPlan.deleteMany({ where: { id, userId } });
|
|
return { ok: true, refundedCents: refundCents };
|
|
});
|
|
});
|
|
|
|
app.post("/fixed-plans/:id/true-up-actual", opts.mutationRateLimit, async (req, reply) => {
|
|
const params = z.object({ id: z.string().min(1) }).safeParse(req.params);
|
|
const parsed = z.object({ actualCents: z.number().int().min(0) }).safeParse(req.body);
|
|
if (!params.success || !parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid payload" });
|
|
}
|
|
|
|
const planId = params.data.id;
|
|
const userId = req.userId;
|
|
const actualCents = parsed.data.actualCents;
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const plan = await tx.fixedPlan.findFirst({
|
|
where: { id: planId, userId },
|
|
select: {
|
|
id: true,
|
|
amountMode: true,
|
|
totalCents: true,
|
|
fundedCents: true,
|
|
currentFundedCents: true,
|
|
dueOn: true,
|
|
},
|
|
});
|
|
if (!plan) {
|
|
const err: any = new Error("Plan not found");
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
if ((plan.amountMode ?? "fixed") !== "estimated") {
|
|
const err: any = new Error("True-up is only available for estimated plans");
|
|
err.statusCode = 400;
|
|
err.code = "PLAN_NOT_ESTIMATED";
|
|
throw err;
|
|
}
|
|
|
|
const previousTargetCents = Number(plan.totalCents ?? 0n);
|
|
const deltaCents = actualCents - previousTargetCents;
|
|
const currentFundedCents = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
|
|
let autoPulledCents = 0;
|
|
let refundedCents = 0;
|
|
let nextFundedCents = currentFundedCents;
|
|
|
|
if (deltaCents > 0) {
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
const availableBudget = categories.reduce(
|
|
(sum, cat) => sum + Number(cat.balanceCents ?? 0n),
|
|
0
|
|
);
|
|
const desiredPull = Math.min(deltaCents, Math.max(0, availableBudget));
|
|
if (desiredPull > 0) {
|
|
const shareResult = opts.computeWithdrawShares(categories, desiredPull);
|
|
if (shareResult.ok) {
|
|
autoPulledCents = shareResult.shares.reduce((sum, share) => sum + share.share, 0);
|
|
for (const share of shareResult.shares) {
|
|
if (share.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: share.id },
|
|
data: { balanceCents: { decrement: BigInt(share.share) } },
|
|
});
|
|
}
|
|
if (autoPulledCents > 0) {
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(autoPulledCents),
|
|
incomeId: null,
|
|
},
|
|
});
|
|
}
|
|
nextFundedCents += autoPulledCents;
|
|
}
|
|
}
|
|
} else if (deltaCents < 0) {
|
|
const categories = await tx.variableCategory.findMany({
|
|
where: { userId },
|
|
select: { id: true, percent: true, balanceCents: true },
|
|
orderBy: [{ priority: "asc" }, { name: "asc" }],
|
|
});
|
|
const excessFunded = Math.max(0, currentFundedCents - actualCents);
|
|
if (excessFunded > 0) {
|
|
const shareResult = opts.computeDepositShares(categories, excessFunded);
|
|
if (shareResult.ok) {
|
|
refundedCents = shareResult.shares.reduce((sum, share) => sum + share.share, 0);
|
|
for (const share of shareResult.shares) {
|
|
if (share.share <= 0) continue;
|
|
await tx.variableCategory.update({
|
|
where: { id: share.id },
|
|
data: { balanceCents: { increment: BigInt(share.share) } },
|
|
});
|
|
}
|
|
if (refundedCents > 0) {
|
|
await tx.allocation.create({
|
|
data: {
|
|
userId,
|
|
kind: "fixed",
|
|
toId: planId,
|
|
amountCents: BigInt(-refundedCents),
|
|
incomeId: null,
|
|
},
|
|
});
|
|
}
|
|
nextFundedCents = Math.max(0, currentFundedCents - refundedCents);
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
await tx.fixedPlan.update({
|
|
where: { id: planId },
|
|
data: {
|
|
totalCents: BigInt(actualCents),
|
|
fundedCents: BigInt(nextFundedCents),
|
|
currentFundedCents: BigInt(nextFundedCents),
|
|
actualCents: BigInt(actualCents),
|
|
actualCycleDueOn: plan.dueOn,
|
|
actualRecordedAt: now,
|
|
},
|
|
});
|
|
|
|
const remainingShortfallCents = Math.max(0, actualCents - nextFundedCents);
|
|
return {
|
|
ok: true,
|
|
planId,
|
|
amountMode: "estimated" as const,
|
|
previousTargetCents,
|
|
actualCents,
|
|
deltaCents,
|
|
autoPulledCents,
|
|
refundedCents,
|
|
remainingShortfallCents,
|
|
fundedCents: nextFundedCents,
|
|
totalCents: actualCents,
|
|
};
|
|
});
|
|
});
|
|
|
|
app.get("/fixed-plans/due", async (req, reply) => {
|
|
const Query = z.object({
|
|
asOf: z.string().datetime().optional(),
|
|
daysAhead: z.coerce.number().int().min(0).max(60).default(0),
|
|
});
|
|
const parsed = Query.safeParse(req.query);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid query" });
|
|
}
|
|
|
|
const userId = req.userId;
|
|
const now = new Date();
|
|
const asOfDate = parsed.data.asOf ? new Date(parsed.data.asOf) : now;
|
|
const user = await app.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { timezone: true },
|
|
});
|
|
const userTimezone = user?.timezone || "America/New_York";
|
|
const todayUser = getUserMidnight(userTimezone, asOfDate);
|
|
const cutoff = new Date(todayUser.getTime() + parsed.data.daysAhead * DAY_MS);
|
|
|
|
const plans = await app.prisma.fixedPlan.findMany({
|
|
where: { userId },
|
|
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
totalCents: true,
|
|
fundedCents: true,
|
|
currentFundedCents: true,
|
|
dueOn: true,
|
|
priority: true,
|
|
},
|
|
});
|
|
|
|
const items = plans
|
|
.map((p) => {
|
|
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n);
|
|
const total = Number(p.totalCents ?? 0n);
|
|
const remaining = Math.max(0, total - funded);
|
|
const percentFunded = total > 0 ? Math.min(100, Math.round((funded / total) * 100)) : 0;
|
|
const dueDate = new Date(p.dueOn);
|
|
const dueUser = getUserMidnightFromDateOnly(userTimezone, dueDate);
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
dueOn: dueUser.toISOString(),
|
|
remainingCents: remaining,
|
|
percentFunded,
|
|
isDue: dueUser.getTime() <= todayUser.getTime(),
|
|
isOverdue: dueUser.getTime() < todayUser.getTime(),
|
|
};
|
|
})
|
|
.filter((p) => {
|
|
const dueDate = new Date(p.dueOn);
|
|
return (
|
|
getUserMidnightFromDateOnly(userTimezone, dueDate).getTime() <=
|
|
cutoff.getTime()
|
|
);
|
|
});
|
|
|
|
return { items, asOfISO: cutoff.toISOString() };
|
|
});
|
|
|
|
app.post("/fixed-plans/:id/pay-now", opts.mutationRateLimit, async (req, reply) => {
|
|
const Params = z.object({ id: z.string().min(1) });
|
|
const Body = z.object({
|
|
occurredAtISO: z.string().datetime().optional(),
|
|
overrideDueOnISO: z.string().datetime().optional(),
|
|
fundingSource: z.enum(["funded", "savings", "deficit"]).optional(),
|
|
savingsCategoryId: z.string().optional(),
|
|
note: z.string().trim().max(500).optional(),
|
|
});
|
|
const params = Params.safeParse(req.params);
|
|
const parsed = Body.safeParse(req.body);
|
|
if (!params.success || !parsed.success) {
|
|
return reply.code(400).send({ message: "Invalid payload" });
|
|
}
|
|
|
|
const userId = req.userId;
|
|
const id = params.data.id;
|
|
const { occurredAtISO, overrideDueOnISO, fundingSource, savingsCategoryId, note } = parsed.data;
|
|
|
|
return await app.prisma.$transaction(async (tx) => {
|
|
const plan = await tx.fixedPlan.findFirst({
|
|
where: { id, userId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
amountMode: true,
|
|
estimatedCents: true,
|
|
totalCents: true,
|
|
fundedCents: true,
|
|
currentFundedCents: true,
|
|
dueOn: true,
|
|
frequency: true,
|
|
autoPayEnabled: true,
|
|
nextPaymentDate: true,
|
|
paymentSchedule: true,
|
|
},
|
|
});
|
|
if (!plan) {
|
|
const err: any = new Error("Plan not found");
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
|
|
const total = Number(plan.totalCents ?? 0n);
|
|
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
|
|
const shortage = Math.max(0, total - funded);
|
|
const isOneTime = !plan.frequency || plan.frequency === "one-time";
|
|
|
|
let savingsUsed = false;
|
|
let deficitCovered = false;
|
|
|
|
const source = funded >= total ? (fundingSource ?? "funded") : fundingSource;
|
|
|
|
if (shortage > 0) {
|
|
if (!source) {
|
|
const err: any = new Error("Insufficient funds: specify fundingSource (savings or deficit)");
|
|
err.statusCode = 400;
|
|
err.code = "INSUFFICIENT_FUNDS";
|
|
throw err;
|
|
}
|
|
if (source === "savings") {
|
|
if (!savingsCategoryId) {
|
|
const err: any = new Error("savingsCategoryId required when fundingSource is savings");
|
|
err.statusCode = 400;
|
|
err.code = "SAVINGS_CATEGORY_REQUIRED";
|
|
throw err;
|
|
}
|
|
const cat = await tx.variableCategory.findFirst({
|
|
where: { id: savingsCategoryId, userId },
|
|
select: { id: true, name: true, isSavings: true, balanceCents: true },
|
|
});
|
|
if (!cat) {
|
|
const err: any = new Error("Savings category not found");
|
|
err.statusCode = 404;
|
|
err.code = "SAVINGS_NOT_FOUND";
|
|
throw err;
|
|
}
|
|
if (!cat.isSavings) {
|
|
const err: any = new Error("Selected category is not savings");
|
|
err.statusCode = 400;
|
|
err.code = "NOT_SAVINGS_CATEGORY";
|
|
throw err;
|
|
}
|
|
const bal = Number(cat.balanceCents ?? 0n);
|
|
if (shortage > bal) {
|
|
const err: any = new Error("Savings balance insufficient to cover shortage");
|
|
err.statusCode = 400;
|
|
err.code = "OVERDRAFT_SAVINGS";
|
|
throw err;
|
|
}
|
|
await tx.variableCategory.update({
|
|
where: { id: cat.id },
|
|
data: { balanceCents: opts.toBig(bal - shortage) },
|
|
});
|
|
await tx.transaction.create({
|
|
data: {
|
|
userId,
|
|
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
|
|
kind: "variable_spend",
|
|
amountCents: opts.toBig(shortage),
|
|
categoryId: cat.id,
|
|
planId: null,
|
|
note: `Covered shortage for ${plan.name}`,
|
|
receiptUrl: null,
|
|
isReconciled: false,
|
|
},
|
|
});
|
|
savingsUsed = true;
|
|
} else if (source === "deficit") {
|
|
deficitCovered = true;
|
|
}
|
|
}
|
|
|
|
const user = await tx.user.findUnique({
|
|
where: { id: userId },
|
|
select: { incomeType: true, timezone: true },
|
|
});
|
|
if (!user) {
|
|
const err: any = new Error("User not found");
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
const userTimezone = user.timezone ?? "America/New_York";
|
|
|
|
const updateData: any = {
|
|
fundedCents: 0n,
|
|
currentFundedCents: 0n,
|
|
};
|
|
const isEstimatedPlan = plan.amountMode === "estimated";
|
|
|
|
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
|
|
if (user.incomeType === "regular" && hasPaymentSchedule) {
|
|
updateData.needsFundingThisPeriod = true;
|
|
}
|
|
|
|
let nextDue = plan.dueOn;
|
|
if (overrideDueOnISO) {
|
|
nextDue = getUserMidnightFromDateOnly(userTimezone, new Date(overrideDueOnISO));
|
|
updateData.dueOn = nextDue;
|
|
} else {
|
|
let frequency = plan.frequency;
|
|
if (!frequency && plan.paymentSchedule) {
|
|
const schedule = plan.paymentSchedule as any;
|
|
frequency = schedule.frequency;
|
|
}
|
|
|
|
if (frequency && frequency !== "one-time") {
|
|
nextDue = opts.calculateNextDueDate(plan.dueOn, frequency as any, userTimezone);
|
|
updateData.dueOn = nextDue;
|
|
}
|
|
}
|
|
if (isEstimatedPlan) {
|
|
const estimate = Number(plan.estimatedCents ?? 0n);
|
|
updateData.totalCents = BigInt(Math.max(0, estimate));
|
|
updateData.actualCents = null;
|
|
updateData.actualCycleDueOn = null;
|
|
updateData.actualRecordedAt = null;
|
|
}
|
|
if (plan.autoPayEnabled) {
|
|
updateData.nextPaymentDate = nextDue;
|
|
}
|
|
|
|
const updatedPlan = isOneTime
|
|
? null
|
|
: await tx.fixedPlan.update({
|
|
where: { id: plan.id },
|
|
data: updateData,
|
|
select: { id: true, dueOn: true },
|
|
});
|
|
|
|
const paymentTx = await tx.transaction.create({
|
|
data: {
|
|
userId,
|
|
occurredAt: occurredAtISO ? new Date(occurredAtISO) : new Date(),
|
|
kind: "fixed_payment",
|
|
amountCents: opts.toBig(total),
|
|
categoryId: null,
|
|
planId: plan.id,
|
|
note: note?.trim() ? note.trim() : null,
|
|
receiptUrl: null,
|
|
isReconciled: false,
|
|
},
|
|
select: { id: true, occurredAt: true },
|
|
});
|
|
|
|
if (isOneTime) {
|
|
await tx.fixedPlan.deleteMany({ where: { id: plan.id, userId } });
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
planId: plan.id,
|
|
transactionId: paymentTx.id,
|
|
nextDueOn: updatedPlan?.dueOn?.toISOString() ?? null,
|
|
savingsUsed,
|
|
deficitCovered,
|
|
shortageCents: shortage,
|
|
};
|
|
});
|
|
});
|
|
};
|
|
|
|
export default fixedPlansRoutes;
|