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

5
.env
View File

@@ -30,10 +30,9 @@ EMAIL_FROM=SkyMoney Budget <no-reply@skymoneybudget.com>
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
EMAIL_REPLY_TO=support@skymoneybudget.com
UPDATE_NOTICE_VERSION=3
UPDATE_NOTICE_VERSION=4
UPDATE_NOTICE_TITLE=SkyMoney Update
UPDATE_NOTICE_BODY=We shipped account security improvements, including a new password reset flow and stronger session protections.
UPDATE_NOTICE_BODY=You can now set fixed expenses as Estimated Bills for variable amounts (like utilities), apply actual bill amounts each cycle for instant true-up, and auto-adjust surplus/shortfall against available budget.
ALLOW_INSECURE_AUTH_FOR_DEV=false
JWT_ISSUER=skymoney-api
JWT_AUDIENCE=skymoney-web

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

View File

@@ -11,6 +11,8 @@ export type NewPlan = {
name: string;
totalCents: number; // >= 0
fundedCents?: number; // optional, default 0
amountMode?: "fixed" | "estimated";
estimatedCents?: number | null;
priority: number; // int
dueOn: string; // ISO date
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
@@ -21,6 +23,20 @@ export type NewPlan = {
};
export type UpdatePlan = Partial<NewPlan>;
export type TrueUpActualResult = {
ok: boolean;
planId: string;
amountMode: "estimated";
previousTargetCents: number;
actualCents: number;
deltaCents: number;
autoPulledCents: number;
refundedCents: number;
remainingShortfallCents: number;
fundedCents: number;
totalCents: number;
};
export const fixedPlansApi = {
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
update: (id: string, body: UpdatePlan) =>
@@ -90,4 +106,6 @@ export const fixedPlansApi = {
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
trueUpActual: (id: string, body: { actualCents: number }) =>
apiPost<TrueUpActualResult>(`/fixed-plans/${id}/true-up-actual`, body),
};

View File

@@ -23,6 +23,11 @@ import { fixedPlansApi } from "../../api/fixedPlans";
type FixedPlan = {
id: string;
name: string;
amountMode?: "fixed" | "estimated";
estimatedCents?: number | null;
actualCents?: number | null;
actualRecordedAt?: string | null;
actualCycleDueOn?: string | null;
totalCents: number;
fundedCents: number;
priority: number;
@@ -61,6 +66,9 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
const [initialized, setInitialized] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deletePrompt, setDeletePrompt] = useState<LocalPlan | null>(null);
const [actualInputs, setActualInputs] = useState<Record<string, string>>({});
const [applyingActualByPlan, setApplyingActualByPlan] = useState<Record<string, boolean>>({});
const [trueUpMessages, setTrueUpMessages] = useState<Record<string, string>>({});
useEffect(() => {
if (!initialized && plans.length > 0) {
@@ -98,6 +106,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
if (
local.name !== server.name ||
local.totalCents !== server.totalCents ||
(local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed") ||
(local.estimatedCents ?? null) !== (server.estimatedCents ?? null) ||
local.priority !== server.priority ||
local.dueOn !== server.dueOn ||
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
@@ -127,6 +137,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed");
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
@@ -162,6 +173,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
const newPlan: LocalPlan = {
id: tempId,
name: name.trim(),
amountMode,
estimatedCents: amountMode === "estimated" ? totalCents : null,
totalCents,
fundedCents: 0,
priority: parsedPriority || localPlans.length + 1,
@@ -180,6 +193,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
setDue(getTodayInTimezone(userTimezone));
setFrequency("monthly");
setAutoPayEnabled(false);
setAmountMode("fixed");
};
function toUserMidnight(iso: string, timezone: string) {
@@ -338,6 +352,24 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
if (patch.priority !== undefined) {
next.priority = Math.max(0, Math.floor(patch.priority));
}
if (patch.amountMode !== undefined) {
const mode = patch.amountMode;
next.amountMode = mode;
if (mode === "fixed") {
next.estimatedCents = null;
} else {
next.estimatedCents = next.estimatedCents ?? next.totalCents;
}
}
if (patch.estimatedCents !== undefined) {
const nextEstimate = Math.max(0, Math.round(patch.estimatedCents ?? 0));
next.estimatedCents = nextEstimate;
const hasActualForCycle =
!!next.actualCycleDueOn && next.actualCycleDueOn === next.dueOn;
if ((next.amountMode ?? "fixed") === "estimated" && !hasActualForCycle) {
next.totalCents = nextEstimate;
}
}
if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) {
next.nextPaymentDate = null;
}
@@ -407,6 +439,11 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
name: plan.name,
totalCents: plan.totalCents,
fundedCents: plan.fundedCents ?? 0,
amountMode: plan.amountMode ?? "fixed",
estimatedCents:
(plan.amountMode ?? "fixed") === "estimated"
? (plan.estimatedCents ?? plan.totalCents)
: null,
priority: plan.priority,
dueOn: plan.dueOn,
frequency: plan.frequency,
@@ -455,6 +492,10 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
patch.frequency = local.frequency;
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
patch.nextPaymentDate = local.nextPaymentDate ?? null;
if ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed"))
patch.amountMode = local.amountMode ?? "fixed";
if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null))
patch.estimatedCents = local.estimatedCents ?? 0;
if (Object.keys(patch).length > 0) {
await fixedPlansApi.update(local.id, patch);
@@ -493,6 +534,49 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
}
}, [localPlans, plans, refetch, resetToServer, push]);
const onApplyActual = useCallback(
async (plan: LocalPlan) => {
if (plan._isNew) {
push("err", "Save this plan before applying an actual amount.");
return;
}
const raw = actualInputs[plan.id];
const actualCents = Math.max(0, Math.round((Number(raw) || 0) * 100));
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true }));
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" }));
try {
const res = await fixedPlansApi.trueUpActual(plan.id, { actualCents });
setLocalPlans((prev) =>
prev.map((p) =>
p.id !== plan.id
? p
: {
...p,
totalCents: res.totalCents,
fundedCents: res.fundedCents,
actualCents: res.actualCents,
actualCycleDueOn: p.dueOn,
actualRecordedAt: new Date().toISOString(),
}
)
);
const summary =
res.deltaCents > 0
? `Actual is higher. Pulled $${(res.autoPulledCents / 100).toFixed(2)} from available budget. Remaining shortfall $${(res.remainingShortfallCents / 100).toFixed(2)}.`
: res.deltaCents < 0
? `Actual is lower. Returned $${(res.refundedCents / 100).toFixed(2)} to available budget.`
: "Actual matches estimate. No adjustment needed.";
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: summary }));
push("ok", "Actual amount applied.");
} catch (err: any) {
push("err", err?.message ?? "Unable to apply actual amount.");
} finally {
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: false }));
}
},
[actualInputs, push]
);
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
if (isLoading)
@@ -531,6 +615,14 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
{/* Add form */}
<form onSubmit={onAdd} className="settings-add-form">
<select
className="input"
value={amountMode}
onChange={(e) => setAmountMode((e.target.value as "fixed" | "estimated") || "fixed")}
>
<option value="fixed">Fixed amount</option>
<option value="estimated">Estimated bill</option>
</select>
<input
className="input"
placeholder="Name"
@@ -539,13 +631,18 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
/>
<input
className="input"
placeholder="Total $"
placeholder={amountMode === "estimated" ? "Estimated $" : "Total $"}
type="number"
min="0"
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
{amountMode === "estimated" && (
<div className="text-xs muted col-span-full">
Tip: Always over-estimate variable bills to avoid due-date shortfalls.
</div>
)}
<input
className="input"
placeholder="Due date"
@@ -608,6 +705,21 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
{/* Details row */}
<div className="settings-plan-details">
<div className="settings-plan-detail">
<span className="label">Bill Type</span>
<select
className="input w-36 text-sm"
value={plan.amountMode ?? "fixed"}
onChange={(e) =>
onEdit(plan.id, {
amountMode: (e.target.value as "fixed" | "estimated") ?? "fixed",
})
}
>
<option value="fixed">Fixed</option>
<option value="estimated">Estimated</option>
</select>
</div>
<div className="settings-plan-detail">
<span className="label">Due</span>
<InlineEditDate
@@ -627,6 +739,15 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
}
/>
</div>
{(plan.amountMode ?? "fixed") === "estimated" && (
<div className="settings-plan-detail">
<span className="label">Estimate</span>
<InlineEditMoney
cents={plan.estimatedCents ?? plan.totalCents}
onChange={(cents) => onEdit(plan.id, { estimatedCents: cents })}
/>
</div>
)}
{aheadCents !== null && (
<div className="settings-plan-badge ahead">
+{new Intl.NumberFormat("en-US", {
@@ -651,6 +772,41 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
</div>
</div>
{(plan.amountMode ?? "fixed") === "estimated" && (
<div className="settings-plan-status planned">
<div className="flex flex-wrap items-end gap-2">
<label className="stack gap-1">
<span className="label">Actual this cycle</span>
<input
className="input w-36"
type="number"
min="0"
step="0.01"
value={actualInputs[plan.id] ?? ""}
onChange={(e) =>
setActualInputs((prev) => ({ ...prev, [plan.id]: e.target.value }))
}
placeholder={((plan.totalCents ?? 0) / 100).toFixed(2)}
/>
</label>
<button
className="btn btn-sm"
onClick={() => void onApplyActual(plan)}
disabled={isSaving || !!applyingActualByPlan[plan.id]}
type="button"
>
{applyingActualByPlan[plan.id] ? "Applying..." : "Apply actual"}
</button>
</div>
<div className="text-xs muted mt-2">
Using a slightly higher estimate helps prevent last-minute shortages.
</div>
{trueUpMessages[plan.id] ? (
<div className="text-xs mt-2">{trueUpMessages[plan.id]}</div>
) : null}
</div>
)}
{/* Actions row */}
<div className="settings-plan-actions">
<label className="settings-checkbox-label">