feat: added estimate fixed expenses
All checks were successful
Deploy / deploy (push) Successful in 1m26s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 22s

This commit is contained in:
2026-03-02 10:49:12 -06:00
parent e0313df24b
commit 301b3f8967
7 changed files with 583 additions and 6 deletions

View File

@@ -3509,6 +3509,8 @@ 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(),
@@ -3527,6 +3529,7 @@ const PlanBody = z.object({
nextPaymentDate: z.string().datetime().optional(),
maxRetryAttempts: z.number().int().min(0).max(10).optional(),
});
const PlanAmountMode = z.enum(["fixed", "estimated"]);
app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
const parsed = PlanBody.safeParse(req.body);
@@ -3538,7 +3541,18 @@ app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
"America/New_York";
const totalBig = toBig(parsed.data.totalCents);
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 = toBig(computedTotalCents);
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
if (fundedBig > totalBig) {
return reply
@@ -3570,6 +3584,14 @@ app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
data: {
userId,
name: parsed.data.name,
amountMode,
estimatedCents:
amountMode === "estimated" && parsed.data.estimatedCents !== undefined
? toBig(parsed.data.estimatedCents)
: null,
actualCents: null,
actualCycleDueOn: null,
actualRecordedAt: null,
totalCents: totalBig,
fundedCents: fundedBig,
currentFundedCents: fundedBig,
@@ -3624,6 +3646,40 @@ app.patch("/fixed-plans/:id", mutationRateLimit, async (req, 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
? 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
? 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 =
@@ -3649,7 +3705,12 @@ app.patch("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
where: { id, userId },
data: {
...patch.data,
...(patch.data.totalCents !== undefined ? { totalCents: total } : {}),
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() }
: {}),
@@ -3719,6 +3780,149 @@ app.delete("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
});
});
app.post("/fixed-plans/:id/true-up-actual", 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 = 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 = 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,
};
});
});
// ----- Fixed plans: due list -----
app.get("/fixed-plans/due", async (req, reply) => {
const Query = z.object({
@@ -3811,6 +4015,8 @@ app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
select: {
id: true,
name: true,
amountMode: true,
estimatedCents: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
@@ -3918,6 +4124,7 @@ app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
fundedCents: 0n,
currentFundedCents: 0n,
};
const isEstimatedPlan = plan.amountMode === "estimated";
// For REGULAR users with payment plans, resume funding after payment
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
@@ -3942,6 +4149,13 @@ app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
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;
}