feat: added estimate fixed expenses
This commit is contained in:
5
.env
5
.env
@@ -30,10 +30,9 @@ EMAIL_FROM=SkyMoney Budget <no-reply@skymoneybudget.com>
|
|||||||
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
||||||
EMAIL_REPLY_TO=support@skymoneybudget.com
|
EMAIL_REPLY_TO=support@skymoneybudget.com
|
||||||
|
|
||||||
UPDATE_NOTICE_VERSION=3
|
UPDATE_NOTICE_VERSION=4
|
||||||
UPDATE_NOTICE_TITLE=SkyMoney Update
|
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
|
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
||||||
JWT_ISSUER=skymoney-api
|
JWT_ISSUER=skymoney-api
|
||||||
JWT_AUDIENCE=skymoney-web
|
JWT_AUDIENCE=skymoney-web
|
||||||
|
|||||||
@@ -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
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
|
amountMode String @default("fixed") // "fixed" | "estimated"
|
||||||
|
estimatedCents BigInt?
|
||||||
|
actualCents BigInt?
|
||||||
|
actualCycleDueOn DateTime?
|
||||||
|
actualRecordedAt DateTime?
|
||||||
cycleStart DateTime
|
cycleStart DateTime
|
||||||
dueOn DateTime
|
dueOn DateTime
|
||||||
totalCents BigInt
|
totalCents BigInt
|
||||||
|
|||||||
@@ -3509,6 +3509,8 @@ const PlanBody = z.object({
|
|||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
totalCents: z.number().int().min(0),
|
totalCents: z.number().int().min(0),
|
||||||
fundedCents: z.number().int().min(0).optional(),
|
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),
|
priority: z.number().int().min(0),
|
||||||
dueOn: z.string().datetime(),
|
dueOn: z.string().datetime(),
|
||||||
cycleStart: z.string().datetime().optional(),
|
cycleStart: z.string().datetime().optional(),
|
||||||
@@ -3527,6 +3529,7 @@ const PlanBody = z.object({
|
|||||||
nextPaymentDate: z.string().datetime().optional(),
|
nextPaymentDate: z.string().datetime().optional(),
|
||||||
maxRetryAttempts: z.number().int().min(0).max(10).optional(),
|
maxRetryAttempts: z.number().int().min(0).max(10).optional(),
|
||||||
});
|
});
|
||||||
|
const PlanAmountMode = z.enum(["fixed", "estimated"]);
|
||||||
|
|
||||||
app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
|
app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
|
||||||
const parsed = PlanBody.safeParse(req.body);
|
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 ??
|
(await app.prisma.user.findUnique({ where: { id: userId }, select: { timezone: true } }))?.timezone ??
|
||||||
"America/New_York";
|
"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);
|
const fundedBig = toBig(parsed.data.fundedCents ?? 0);
|
||||||
if (fundedBig > totalBig) {
|
if (fundedBig > totalBig) {
|
||||||
return reply
|
return reply
|
||||||
@@ -3570,6 +3584,14 @@ app.post("/fixed-plans", mutationRateLimit, async (req, reply) => {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
name: parsed.data.name,
|
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,
|
totalCents: totalBig,
|
||||||
fundedCents: fundedBig,
|
fundedCents: fundedBig,
|
||||||
currentFundedCents: fundedBig,
|
currentFundedCents: fundedBig,
|
||||||
@@ -3624,6 +3646,40 @@ app.patch("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
|
|||||||
.code(400)
|
.code(400)
|
||||||
.send({ message: "fundedCents cannot exceed totalCents" });
|
.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 hasScheduleInPatch = "paymentSchedule" in patch.data;
|
||||||
const paymentSchedule =
|
const paymentSchedule =
|
||||||
@@ -3649,7 +3705,12 @@ app.patch("/fixed-plans/:id", mutationRateLimit, async (req, reply) => {
|
|||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
data: {
|
data: {
|
||||||
...patch.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
|
...(patch.data.fundedCents !== undefined
|
||||||
? { fundedCents: funded, currentFundedCents: funded, lastFundingDate: new Date() }
|
? { 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 -----
|
// ----- Fixed plans: due list -----
|
||||||
app.get("/fixed-plans/due", async (req, reply) => {
|
app.get("/fixed-plans/due", async (req, reply) => {
|
||||||
const Query = z.object({
|
const Query = z.object({
|
||||||
@@ -3811,6 +4015,8 @@ app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
amountMode: true,
|
||||||
|
estimatedCents: true,
|
||||||
totalCents: true,
|
totalCents: true,
|
||||||
fundedCents: true,
|
fundedCents: true,
|
||||||
currentFundedCents: true,
|
currentFundedCents: true,
|
||||||
@@ -3918,6 +4124,7 @@ app.post("/fixed-plans/:id/pay-now", mutationRateLimit, async (req, reply) => {
|
|||||||
fundedCents: 0n,
|
fundedCents: 0n,
|
||||||
currentFundedCents: 0n,
|
currentFundedCents: 0n,
|
||||||
};
|
};
|
||||||
|
const isEstimatedPlan = plan.amountMode === "estimated";
|
||||||
|
|
||||||
// For REGULAR users with payment plans, resume funding after payment
|
// For REGULAR users with payment plans, resume funding after payment
|
||||||
const hasPaymentSchedule = plan.paymentSchedule !== null && plan.paymentSchedule !== undefined;
|
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;
|
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) {
|
if (plan.autoPayEnabled) {
|
||||||
updateData.nextPaymentDate = nextDue;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,8 @@ export type NewPlan = {
|
|||||||
name: string;
|
name: string;
|
||||||
totalCents: number; // >= 0
|
totalCents: number; // >= 0
|
||||||
fundedCents?: number; // optional, default 0
|
fundedCents?: number; // optional, default 0
|
||||||
|
amountMode?: "fixed" | "estimated";
|
||||||
|
estimatedCents?: number | null;
|
||||||
priority: number; // int
|
priority: number; // int
|
||||||
dueOn: string; // ISO date
|
dueOn: string; // ISO date
|
||||||
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
||||||
@@ -21,6 +23,20 @@ export type NewPlan = {
|
|||||||
};
|
};
|
||||||
export type UpdatePlan = Partial<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 = {
|
export const fixedPlansApi = {
|
||||||
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
|
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
|
||||||
update: (id: string, body: UpdatePlan) =>
|
update: (id: string, body: UpdatePlan) =>
|
||||||
@@ -90,4 +106,6 @@ export const fixedPlansApi = {
|
|||||||
availableBudget?: number;
|
availableBudget?: number;
|
||||||
message: string;
|
message: string;
|
||||||
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
|
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
|
||||||
|
trueUpActual: (id: string, body: { actualCents: number }) =>
|
||||||
|
apiPost<TrueUpActualResult>(`/fixed-plans/${id}/true-up-actual`, body),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import { fixedPlansApi } from "../../api/fixedPlans";
|
|||||||
type FixedPlan = {
|
type FixedPlan = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
amountMode?: "fixed" | "estimated";
|
||||||
|
estimatedCents?: number | null;
|
||||||
|
actualCents?: number | null;
|
||||||
|
actualRecordedAt?: string | null;
|
||||||
|
actualCycleDueOn?: string | null;
|
||||||
totalCents: number;
|
totalCents: number;
|
||||||
fundedCents: number;
|
fundedCents: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -61,6 +66,9 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [deletePrompt, setDeletePrompt] = useState<LocalPlan | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!initialized && plans.length > 0) {
|
if (!initialized && plans.length > 0) {
|
||||||
@@ -98,6 +106,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
if (
|
if (
|
||||||
local.name !== server.name ||
|
local.name !== server.name ||
|
||||||
local.totalCents !== server.totalCents ||
|
local.totalCents !== server.totalCents ||
|
||||||
|
(local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed") ||
|
||||||
|
(local.estimatedCents ?? null) !== (server.estimatedCents ?? null) ||
|
||||||
local.priority !== server.priority ||
|
local.priority !== server.priority ||
|
||||||
local.dueOn !== server.dueOn ||
|
local.dueOn !== server.dueOn ||
|
||||||
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
|
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
|
||||||
@@ -127,6 +137,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||||
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
||||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||||
|
const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed");
|
||||||
|
|
||||||
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
|
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
|
||||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||||
@@ -162,6 +173,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
const newPlan: LocalPlan = {
|
const newPlan: LocalPlan = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
amountMode,
|
||||||
|
estimatedCents: amountMode === "estimated" ? totalCents : null,
|
||||||
totalCents,
|
totalCents,
|
||||||
fundedCents: 0,
|
fundedCents: 0,
|
||||||
priority: parsedPriority || localPlans.length + 1,
|
priority: parsedPriority || localPlans.length + 1,
|
||||||
@@ -180,6 +193,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
setDue(getTodayInTimezone(userTimezone));
|
setDue(getTodayInTimezone(userTimezone));
|
||||||
setFrequency("monthly");
|
setFrequency("monthly");
|
||||||
setAutoPayEnabled(false);
|
setAutoPayEnabled(false);
|
||||||
|
setAmountMode("fixed");
|
||||||
};
|
};
|
||||||
|
|
||||||
function toUserMidnight(iso: string, timezone: string) {
|
function toUserMidnight(iso: string, timezone: string) {
|
||||||
@@ -338,6 +352,24 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
if (patch.priority !== undefined) {
|
if (patch.priority !== undefined) {
|
||||||
next.priority = Math.max(0, Math.floor(patch.priority));
|
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) {
|
if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) {
|
||||||
next.nextPaymentDate = null;
|
next.nextPaymentDate = null;
|
||||||
}
|
}
|
||||||
@@ -407,6 +439,11 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
totalCents: plan.totalCents,
|
totalCents: plan.totalCents,
|
||||||
fundedCents: plan.fundedCents ?? 0,
|
fundedCents: plan.fundedCents ?? 0,
|
||||||
|
amountMode: plan.amountMode ?? "fixed",
|
||||||
|
estimatedCents:
|
||||||
|
(plan.amountMode ?? "fixed") === "estimated"
|
||||||
|
? (plan.estimatedCents ?? plan.totalCents)
|
||||||
|
: null,
|
||||||
priority: plan.priority,
|
priority: plan.priority,
|
||||||
dueOn: plan.dueOn,
|
dueOn: plan.dueOn,
|
||||||
frequency: plan.frequency,
|
frequency: plan.frequency,
|
||||||
@@ -455,6 +492,10 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
patch.frequency = local.frequency;
|
patch.frequency = local.frequency;
|
||||||
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
||||||
patch.nextPaymentDate = local.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) {
|
if (Object.keys(patch).length > 0) {
|
||||||
await fixedPlansApi.update(local.id, patch);
|
await fixedPlansApi.update(local.id, patch);
|
||||||
@@ -493,6 +534,49 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
}
|
}
|
||||||
}, [localPlans, plans, refetch, resetToServer, push]);
|
}, [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]);
|
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
@@ -531,6 +615,14 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
|
|
||||||
{/* Add form */}
|
{/* Add form */}
|
||||||
<form onSubmit={onAdd} className="settings-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
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
@@ -539,13 +631,18 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="Total $"
|
placeholder={amountMode === "estimated" ? "Estimated $" : "Total $"}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={total}
|
value={total}
|
||||||
onChange={(e) => setTotal(e.target.value)}
|
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
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="Due date"
|
placeholder="Due date"
|
||||||
@@ -608,6 +705,21 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
|
|
||||||
{/* Details row */}
|
{/* Details row */}
|
||||||
<div className="settings-plan-details">
|
<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">
|
<div className="settings-plan-detail">
|
||||||
<span className="label">Due</span>
|
<span className="label">Due</span>
|
||||||
<InlineEditDate
|
<InlineEditDate
|
||||||
@@ -627,6 +739,15 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{aheadCents !== null && (
|
||||||
<div className="settings-plan-badge ahead">
|
<div className="settings-plan-badge ahead">
|
||||||
+{new Intl.NumberFormat("en-US", {
|
+{new Intl.NumberFormat("en-US", {
|
||||||
@@ -651,6 +772,41 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Actions row */}
|
||||||
<div className="settings-plan-actions">
|
<div className="settings-plan-actions">
|
||||||
<label className="settings-checkbox-label">
|
<label className="settings-checkbox-label">
|
||||||
|
|||||||
Reference in New Issue
Block a user