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

@@ -0,0 +1,6 @@
ALTER TABLE "FixedPlan"
ADD COLUMN IF NOT EXISTS "amountMode" TEXT NOT NULL DEFAULT 'fixed',
ADD COLUMN IF NOT EXISTS "estimatedCents" BIGINT,
ADD COLUMN IF NOT EXISTS "actualCents" BIGINT,
ADD COLUMN IF NOT EXISTS "actualCycleDueOn" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "actualRecordedAt" TIMESTAMP(3);

View File

@@ -70,6 +70,11 @@ model FixedPlan {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
amountMode String @default("fixed") // "fixed" | "estimated"
estimatedCents BigInt?
actualCents BigInt?
actualCycleDueOn DateTime?
actualRecordedAt DateTime?
cycleStart DateTime
dueOn DateTime
totalCents BigInt

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;
}

View File

@@ -0,0 +1,179 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
let app: FastifyInstance;
const CSRF = "test-csrf";
function mutate(path: string) {
return request(app.server)
.post(path)
.set("x-user-id", U)
.set("x-csrf-token", CSRF)
.set("Cookie", [`csrf=${CSRF}`]);
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
beforeEach(async () => {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ userId: U, name: "Essentials", percent: 60, priority: 10, balanceCents: 10_000n },
{ userId: U, name: "Savings", percent: 40, priority: 20, balanceCents: 10_000n, isSavings: true },
],
});
});
afterAll(async () => {
if (app) await app.close();
await closePrisma();
});
describe("estimated fixed plans true-up", () => {
it("creates estimated mode plans with estimate fields", async () => {
const dueOn = new Date("2026-04-01T00:00:00.000Z").toISOString();
const res = await mutate("/fixed-plans").send({
name: "Water",
amountMode: "estimated",
estimatedCents: 12000,
totalCents: 12000,
fundedCents: 0,
priority: 10,
dueOn,
frequency: "monthly",
autoPayEnabled: false,
});
expect(res.status).toBe(201);
const created = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: res.body.id } });
expect(created.amountMode).toBe("estimated");
expect(Number(created.estimatedCents ?? 0n)).toBe(12000);
expect(Number(created.totalCents ?? 0n)).toBe(12000);
});
it("true-up deficit auto-pulls from available budget and leaves remaining shortfall", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Deficit",
amountMode: "estimated",
estimatedCents: 10000n,
totalCents: 10000n,
fundedCents: 6000n,
currentFundedCents: 6000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 23000 });
expect(res.status).toBe(200);
expect(res.body.deltaCents).toBe(13000);
expect(res.body.autoPulledCents).toBe(13000);
expect(res.body.remainingShortfallCents).toBe(4000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(23000);
expect(Number(updated.currentFundedCents ?? 0n)).toBe(19000);
expect(Number(updated.actualCents ?? 0n)).toBe(23000);
expect(updated.actualCycleDueOn?.toISOString()).toBe(updated.dueOn.toISOString());
expect(updated.actualRecordedAt).toBeTruthy();
});
it("true-up surplus refunds funded excess back to available budget", async () => {
const categoriesBefore = await prisma.variableCategory.findMany({ where: { userId: U } });
const budgetBefore = categoriesBefore.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Surplus",
amountMode: "estimated",
estimatedCents: 15000n,
totalCents: 15000n,
fundedCents: 15000n,
currentFundedCents: 15000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 9000 });
expect(res.status).toBe(200);
expect(res.body.deltaCents).toBe(-6000);
expect(res.body.refundedCents).toBe(6000);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(9000);
expect(Number(updated.currentFundedCents ?? 0n)).toBe(9000);
const categoriesAfter = await prisma.variableCategory.findMany({ where: { userId: U } });
const budgetAfter = categoriesAfter.reduce((sum, c) => sum + Number(c.balanceCents ?? 0n), 0);
expect(budgetAfter - budgetBefore).toBe(6000);
});
it("rejects true-up for fixed mode plans", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Rent Fixed",
amountMode: "fixed",
totalCents: 120000n,
fundedCents: 120000n,
currentFundedCents: 120000n,
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/true-up-actual`).send({ actualCents: 100000 });
expect(res.status).toBe(400);
expect(res.body.code).toBe("PLAN_NOT_ESTIMATED");
});
it("pay-now rollover resets estimated plan target back to estimate and clears cycle actual metadata", async () => {
const plan = await prisma.fixedPlan.create({
data: {
userId: U,
name: "Water Rollover",
amountMode: "estimated",
estimatedCents: 10000n,
totalCents: 16000n,
fundedCents: 16000n,
currentFundedCents: 16000n,
actualCents: 16000n,
actualCycleDueOn: new Date("2026-04-01T00:00:00.000Z"),
actualRecordedAt: new Date(),
priority: 10,
cycleStart: new Date("2026-03-01T00:00:00.000Z"),
dueOn: new Date("2026-04-01T00:00:00.000Z"),
frequency: "monthly",
},
});
const res = await mutate(`/fixed-plans/${plan.id}/pay-now`).send({});
expect(res.status).toBe(200);
const updated = await prisma.fixedPlan.findUniqueOrThrow({ where: { id: plan.id } });
expect(Number(updated.totalCents ?? 0n)).toBe(10000);
expect(updated.actualCents).toBeNull();
expect(updated.actualCycleDueOn).toBeNull();
expect(updated.actualRecordedAt).toBeNull();
});
});