From 301b3f8967d361d20d5dfc96b3ec4c8f4424cf65 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Mon, 2 Mar 2026 10:49:12 -0600 Subject: [PATCH] feat: added estimate fixed expenses --- .env | 5 +- .../migration.sql | 6 + api/prisma/schema.prisma | 5 + api/src/server.ts | 218 +++++++++++++++++- .../fixed-plans.estimated-true-up.test.ts | 179 ++++++++++++++ web/src/api/fixedPlans.ts | 18 ++ web/src/pages/settings/PlansSettings.tsx | 158 ++++++++++++- 7 files changed, 583 insertions(+), 6 deletions(-) create mode 100644 api/prisma/migrations/20260302010000_add_estimated_fixed_plans/migration.sql create mode 100644 api/tests/fixed-plans.estimated-true-up.test.ts diff --git a/.env b/.env index 0a723ba..64f2583 100644 --- a/.env +++ b/.env @@ -30,10 +30,9 @@ EMAIL_FROM=SkyMoney Budget 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 diff --git a/api/prisma/migrations/20260302010000_add_estimated_fixed_plans/migration.sql b/api/prisma/migrations/20260302010000_add_estimated_fixed_plans/migration.sql new file mode 100644 index 0000000..d840bbf --- /dev/null +++ b/api/prisma/migrations/20260302010000_add_estimated_fixed_plans/migration.sql @@ -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); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 1edbc12..220e4aa 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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 diff --git a/api/src/server.ts b/api/src/server.ts index 15fd15d..bffbf63 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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; } diff --git a/api/tests/fixed-plans.estimated-true-up.test.ts b/api/tests/fixed-plans.estimated-true-up.test.ts new file mode 100644 index 0000000..8933ea6 --- /dev/null +++ b/api/tests/fixed-plans.estimated-true-up.test.ts @@ -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(); + }); +}); diff --git a/web/src/api/fixedPlans.ts b/web/src/api/fixedPlans.ts index 5e5036b..b2d5df4 100644 --- a/web/src/api/fixedPlans.ts +++ b/web/src/api/fixedPlans.ts @@ -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; +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(`/fixed-plans/${id}/true-up-actual`, body), }; diff --git a/web/src/pages/settings/PlansSettings.tsx b/web/src/pages/settings/PlansSettings.tsx index 4a9e608..dcb442c 100644 --- a/web/src/pages/settings/PlansSettings.tsx +++ b/web/src/pages/settings/PlansSettings.tsx @@ -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( const [initialized, setInitialized] = useState(false); const [isSaving, setIsSaving] = useState(false); const [deletePrompt, setDeletePrompt] = useState(null); + const [actualInputs, setActualInputs] = useState>({}); + const [applyingActualByPlan, setApplyingActualByPlan] = useState>({}); + const [trueUpMessages, setTrueUpMessages] = useState>({}); useEffect(() => { if (!initialized && plans.length > 0) { @@ -98,6 +106,8 @@ const PlansSettings = forwardRef( 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( 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( 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( setDue(getTodayInTimezone(userTimezone)); setFrequency("monthly"); setAutoPayEnabled(false); + setAmountMode("fixed"); }; function toUserMidnight(iso: string, timezone: string) { @@ -338,6 +352,24 @@ const PlansSettings = forwardRef( 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( 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( 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( } }, [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( {/* Add form */}
+ ( /> setTotal(e.target.value)} /> + {amountMode === "estimated" && ( +
+ Tip: Always over-estimate variable bills to avoid due-date shortfalls. +
+ )} ( {/* Details row */}
+
+ Bill Type + +
Due ( } />
+ {(plan.amountMode ?? "fixed") === "estimated" && ( +
+ Estimate + onEdit(plan.id, { estimatedCents: cents })} + /> +
+ )} {aheadCents !== null && (
+{new Intl.NumberFormat("en-US", { @@ -651,6 +772,41 @@ const PlansSettings = forwardRef(
+ {(plan.amountMode ?? "fixed") === "estimated" && ( +
+
+ + +
+
+ Using a slightly higher estimate helps prevent last-minute shortages. +
+ {trueUpMessages[plan.id] ? ( +
{trueUpMessages[plan.id]}
+ ) : null} +
+ )} + {/* Actions row */}