feat: added estimate fixed expenses
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
179
api/tests/fixed-plans.estimated-true-up.test.ts
Normal file
179
api/tests/fixed-plans.estimated-true-up.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user