phase 5: fixed expense logic simplified and compacted
All checks were successful
Deploy / deploy (push) Successful in 1m31s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 26s

This commit is contained in:
2026-03-17 20:28:08 -05:00
parent 181c3bdc9e
commit 020d55a77e
6 changed files with 1675 additions and 1512 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest"; import request from "supertest";
import { randomUUID } from "node:crypto";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import appFactory from "./appFactory"; import appFactory from "./appFactory";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers"; import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -16,15 +18,75 @@ beforeEach(async () => {
}); });
afterAll(async () => { afterAll(async () => {
if (app) {
await app.close(); await app.close();
}
await closePrisma(); await closePrisma();
}); });
function getUserMidnightFromDateOnly(timezone: string, date: Date): Date {
const zoned = toZonedTime(date, timezone);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
function calculateNextDueDateLikeServer(
currentDueDate: Date,
frequency: string,
timezone: string = "UTC"
): Date {
const base = getUserMidnightFromDateOnly(timezone, currentDueDate);
const zoned = toZonedTime(base, timezone);
switch (frequency) {
case "weekly":
zoned.setUTCDate(zoned.getUTCDate() + 7);
break;
case "biweekly":
zoned.setUTCDate(zoned.getUTCDate() + 14);
break;
case "monthly": {
const targetDay = zoned.getUTCDate();
const nextMonth = zoned.getUTCMonth() + 1;
const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12);
const nextMonthIndex = nextMonth % 12;
const lastDay = new Date(
Date.UTC(nextYear, nextMonthIndex + 1, 0)
).getUTCDate();
zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay));
break;
}
default:
return base;
}
zoned.setUTCHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
describe("Payment-Triggered Rollover", () => { describe("Payment-Triggered Rollover", () => {
async function getUserTimezoneOrDefault() {
const user = await prisma.user.findUnique({
where: { id: U },
select: { timezone: true },
});
return user?.timezone ?? "America/New_York";
}
function postTransactionsWithCsrf() {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.post("/transactions")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
it("advances due date for weekly frequency on payment", async () => { it("advances due date for weekly frequency on payment", async () => {
// Create a fixed plan with weekly frequency // Create a fixed plan with weekly frequency
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "Weekly Subscription", name: "Weekly Subscription",
totalCents: 1000n, totalCents: 1000n,
@@ -38,14 +100,13 @@ describe("Payment-Triggered Rollover", () => {
}); });
// Make payment // Make payment
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 1000, amountCents: 1000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
if (txRes.status !== 200) { if (txRes.status !== 200) {
@@ -58,12 +119,19 @@ describe("Payment-Triggered Rollover", () => {
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n); expect(updated?.fundedCents).toBe(0n);
expect(updated?.currentFundedCents).toBe(0n); expect(updated?.currentFundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2025-12-08T00:00:00.000Z"); const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"weekly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
}); });
it("advances due date for biweekly frequency on payment", async () => { it("advances due date for biweekly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "Biweekly Bill", name: "Biweekly Bill",
totalCents: 5000n, totalCents: 5000n,
@@ -76,26 +144,32 @@ describe("Payment-Triggered Rollover", () => {
}, },
}); });
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 5000, amountCents: 5000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
expect(txRes.status).toBe(200); expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n); expect(updated?.fundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2025-12-15T00:00:00.000Z"); const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"biweekly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
}); });
it("advances due date for monthly frequency on payment", async () => { it("advances due date for monthly frequency on payment", async () => {
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "Monthly Rent", name: "Monthly Rent",
totalCents: 100000n, totalCents: 100000n,
@@ -108,27 +182,33 @@ describe("Payment-Triggered Rollover", () => {
}, },
}); });
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 100000, amountCents: 100000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
expect(txRes.status).toBe(200); expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n); expect(updated?.fundedCents).toBe(0n);
expect(updated?.dueOn.toISOString()).toBe("2026-01-01T00:00:00.000Z"); const userTimezone = await getUserTimezoneOrDefault();
const expectedDue = calculateNextDueDateLikeServer(
new Date("2025-12-01T00:00:00Z"),
"monthly",
userTimezone
);
expect(updated?.dueOn.toISOString()).toBe(expectedDue.toISOString());
}); });
it("does not advance due date for one-time frequency", async () => { it("deletes one-time plan after payment", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z"); const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "One-time Expense", name: "One-time Expense",
totalCents: 2000n, totalCents: 2000n,
@@ -141,28 +221,26 @@ describe("Payment-Triggered Rollover", () => {
}, },
}); });
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 2000, amountCents: 2000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
expect(txRes.status).toBe(200); expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n); expect(updated).toBeNull();
// Due date should remain unchanged for one-time expenses
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
}); });
it("does not advance due date when no frequency is set", async () => { it("treats null frequency as one-time and deletes plan after payment", async () => {
const originalDueDate = new Date("2025-12-01T00:00:00Z"); const originalDueDate = new Date("2025-12-01T00:00:00Z");
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "Manual Bill", name: "Manual Bill",
totalCents: 3000n, totalCents: 3000n,
@@ -175,27 +253,25 @@ describe("Payment-Triggered Rollover", () => {
}, },
}); });
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 3000, amountCents: 3000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
expect(txRes.status).toBe(200); expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n); expect(updated).toBeNull();
// Due date should remain unchanged when no frequency
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
}); });
it("prevents payment when insufficient funded amount", async () => { it("prevents payment when insufficient funded amount", async () => {
const plan = await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: randomUUID(),
userId: U, userId: U,
name: "Underfunded Bill", name: "Underfunded Bill",
totalCents: 10000n, totalCents: 10000n,
@@ -209,18 +285,17 @@ describe("Payment-Triggered Rollover", () => {
}); });
// Try to pay more than funded amount // Try to pay more than funded amount
const txRes = await request(app.server) const txRes = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
occurredAtISO: "2025-11-27T12:00:00Z", occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 10000, amountCents: 10000,
planId: plan.id, planId: plan.id,
isReconciled: true,
}); });
expect(txRes.status).toBe(400); expect(txRes.status).toBe(400);
expect(txRes.body.code).toBe("OVERDRAFT_PLAN"); expect(txRes.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET");
// Plan should remain unchanged // Plan should remain unchanged
const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } }); const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });

View File

@@ -1,7 +1,8 @@
import request from "supertest"; import request from "supertest";
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
import appFactory from "./appFactory"; import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import { prisma, resetUser, ensureUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
let app: FastifyInstance; let app: FastifyInstance;
@@ -11,7 +12,9 @@ beforeAll(async () => {
}); });
afterAll(async () => { afterAll(async () => {
if (app) {
await app.close(); await app.close();
}
await closePrisma(); await closePrisma();
}); });
@@ -23,16 +26,22 @@ describe("GET /transactions", () => {
await resetUser(U); await resetUser(U);
await ensureUser(U); await ensureUser(U);
catId = cid("c"); const category = await prisma.variableCategory.create({
planId = pid("p");
await prisma.variableCategory.create({
data: { id: catId, userId: U, name: "Groceries", percent: 100, priority: 1, isSavings: false, balanceCents: 5000n },
});
await prisma.fixedPlan.create({
data: { data: {
id: planId, id: randomUUID(),
userId: U,
name: "Groceries",
percent: 100,
priority: 1,
isSavings: false,
balanceCents: 5000n,
},
});
catId = category.id;
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U, userId: U,
name: "Rent", name: "Rent",
totalCents: 10000n, totalCents: 10000n,
@@ -43,6 +52,7 @@ describe("GET /transactions", () => {
fundingMode: "auto-on-deposit", fundingMode: "auto-on-deposit",
}, },
}); });
planId = plan.id;
await prisma.transaction.createMany({ await prisma.transaction.createMany({
data: [ data: [
@@ -100,16 +110,31 @@ describe("POST /transactions", () => {
let catId: string; let catId: string;
let planId: string; let planId: string;
function postTransactionsWithCsrf() {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.post("/transactions")
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
function patchWithCsrf(path: string) {
const csrf = randomUUID().replace(/-/g, "");
return request(app.server)
.patch(path)
.set("x-user-id", U)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`);
}
beforeEach(async () => { beforeEach(async () => {
await resetUser(U); await resetUser(U);
await ensureUser(U); await ensureUser(U);
catId = cid("cat"); const category = await prisma.variableCategory.create({
planId = pid("plan");
await prisma.variableCategory.create({
data: { data: {
id: catId, id: randomUUID(),
userId: U, userId: U,
name: "Dining", name: "Dining",
percent: 100, percent: 100,
@@ -118,10 +143,11 @@ describe("POST /transactions", () => {
balanceCents: 5000n, balanceCents: 5000n,
}, },
}); });
catId = category.id;
await prisma.fixedPlan.create({ const plan = await prisma.fixedPlan.create({
data: { data: {
id: planId, id: randomUUID(),
userId: U, userId: U,
name: "Loan", name: "Loan",
totalCents: 10000n, totalCents: 10000n,
@@ -132,12 +158,11 @@ describe("POST /transactions", () => {
fundingMode: "auto-on-deposit", fundingMode: "auto-on-deposit",
}, },
}); });
planId = plan.id;
}); });
it("spends from a variable category and updates balance", async () => { it("spends from a variable category and updates balance", async () => {
const res = await request(app.server) const res = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
kind: "variable_spend", kind: "variable_spend",
amountCents: 2000, amountCents: 2000,
@@ -157,10 +182,8 @@ describe("POST /transactions", () => {
expect(tx.isReconciled).toBe(true); expect(tx.isReconciled).toBe(true);
}); });
it("prevents overdrawing fixed plans", async () => { it("returns insufficient budget when fixed payment requires unavailable top-up", async () => {
const res = await request(app.server) const res = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
kind: "fixed_payment", kind: "fixed_payment",
amountCents: 400000, // exceeds funded amountCents: 400000, // exceeds funded
@@ -169,13 +192,11 @@ describe("POST /transactions", () => {
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.code).toBe("OVERDRAFT_PLAN"); expect(res.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET");
}); });
it("updates note/receipt and reconciliation via patch", async () => { it("updates note/receipt and reconciliation via patch", async () => {
const created = await request(app.server) const created = await postTransactionsWithCsrf()
.post("/transactions")
.set("x-user-id", U)
.send({ .send({
kind: "variable_spend", kind: "variable_spend",
amountCents: 1000, amountCents: 1000,
@@ -185,9 +206,7 @@ describe("POST /transactions", () => {
expect(created.status).toBe(200); expect(created.status).toBe(200);
const txId = created.body.id; const txId = created.body.id;
const res = await request(app.server) const res = await patchWithCsrf(`/transactions/${txId}`)
.patch(`/transactions/${txId}`)
.set("x-user-id", U)
.send({ .send({
note: "Cleared", note: "Cleared",
isReconciled: true, isReconciled: true,

121
docs/api-phase5-move-log.md Normal file
View File

@@ -0,0 +1,121 @@
# API Phase 5 Move Log
Date: 2026-03-17
Scope: Move `fixed-plans` endpoints out of `api/src/server.ts` into a dedicated route module.
## Route Registration Changes
- Added fixed-plans route import in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:20)
- Registered fixed-plans routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:948)
- New canonical route module: [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
- Removed inline fixed-plans route blocks from `server.ts` to avoid duplicate registration:
- `PATCH /fixed-plans/:id/early-funding`
- `POST /fixed-plans/:id/attempt-final-funding`
- `PATCH /fixed-plans/:id/mark-unpaid`
- `POST /fixed-plans/:id/fund-from-available`
- `POST /fixed-plans/:id/catch-up-funding`
- `POST /fixed-plans`
- `PATCH /fixed-plans/:id`
- `DELETE /fixed-plans/:id`
- `POST /fixed-plans/:id/true-up-actual`
- `GET /fixed-plans/due`
- `POST /fixed-plans/:id/pay-now`
## Endpoint Movements
1. `PATCH /fixed-plans/:id/early-funding`
- Original: `server.ts` line 1414
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:71)
- References:
- [EarlyPaymentPromptModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyPaymentPromptModal.tsx:34)
- [EarlyFundingModal.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/components/EarlyFundingModal.tsx:19)
2. `POST /fixed-plans/:id/attempt-final-funding`
- Original: `server.ts` line 1475
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:131)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:58)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:219)
3. `PATCH /fixed-plans/:id/mark-unpaid`
- Original: `server.ts` line 1635
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:287)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:84)
4. `POST /fixed-plans/:id/fund-from-available`
- Original: `server.ts` line 1674
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:325)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:95)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:461)
5. `POST /fixed-plans/:id/catch-up-funding`
- Original: `server.ts` line 1828
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:478)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:106)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:512)
6. `POST /fixed-plans`
- Original: `server.ts` line 2036
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:659)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:39)
- [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:449)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:43)
7. `PATCH /fixed-plans/:id`
- Original: `server.ts` line 2122
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:747)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:41)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:502)
8. `DELETE /fixed-plans/:id`
- Original: `server.ts` line 2239
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:866)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:42)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:443)
9. `POST /fixed-plans/:id/true-up-actual`
- Original: `server.ts` line 2285
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:911)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:108)
- [PlansSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/PlansSettings.tsx:549)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:79)
10. `GET /fixed-plans/due`
- Original: `server.ts` line 2429
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1054)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:45)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:363)
11. `POST /fixed-plans/:id/pay-now`
- Original: `server.ts` line 2495
- Moved to [fixed-plans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/fixed-plans.ts:1118)
- References:
- [fixedPlans.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/api/fixedPlans.ts:77)
- [DashboardPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/DashboardPage.tsx:649)
- [fixed-plans.estimated-true-up.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/fixed-plans.estimated-true-up.test.ts:170)
## Helper Ownership in Phase 5
- Shared helper injection from `server.ts`:
- `mutationRateLimit`
- `computeDepositShares`
- `computeWithdrawShares`
- `calculateNextDueDate`
- `toBig`
- Route-local helper:
- `DAY_MS` constant for date-window computations
- `PlanBody` / `PlanAmountMode` zod schemas
## Verification
1. Build
- `cd api && npm run build`
2. Focused tests
- `cd api && npm run test -- tests/fixed-plans.estimated-true-up.test.ts tests/payment-rollover.test.ts tests/transactions.test.ts`
- Result: blocked by local DB connectivity (`127.0.0.1:5432` unavailable), suites skipped/failed before endpoint assertions.

View File

@@ -3,9 +3,14 @@
## Goal ## Goal
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves. Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
Current state (2026-03-15): Current state (2026-03-17):
- `server.ts` has ~4.8k lines and 50 endpoint registrations. - `server.ts` still holds most business routes, but Phases 1-5 are complete.
- Duplicate endpoint signatures also exist in `api/src/routes/*` but are not currently registered. - Completed move logs:
- `docs/api-phase1-move-log.md`
- `docs/api-phase2-move-log.md`
- `docs/api-phase3-move-log.md`
- `docs/api-phase4-move-log.md`
- `docs/api-phase5-move-log.md`
## Refactor Guardrails ## Refactor Guardrails
1. Keep route behavior identical while moving code. 1. Keep route behavior identical while moving code.
@@ -26,7 +31,7 @@ When moving a domain:
4. Confirm no duplicate route registrations remain. 4. Confirm no duplicate route registrations remain.
## Shared Helpers (Phase 0) ## Shared Helpers (Phase 0)
Create these helpers first to keep endpoint moves lightweight: Create and/or finish these helpers to keep endpoint moves lightweight:
1. `api/src/services/user-context.ts` 1. `api/src/services/user-context.ts`
- `getUserTimezone(...)` - `getUserTimezone(...)`
@@ -49,15 +54,108 @@ Create these helpers first to keep endpoint moves lightweight:
- Standard typed error object and response body builder. - Standard typed error object and response body builder.
- Keeps endpoint error handling consistent. - Keeps endpoint error handling consistent.
## Move Order (Incremental) ## Progress Snapshot
1. Low-risk endpoints: `health`, `session`, `me`, `user/config`. Completed:
2. `auth` + `account` endpoints. 1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`).
3. `variable-categories` endpoints. 2. Phase 2: `auth` + `account` endpoints.
4. `transactions` endpoints. 3. Phase 3: `variable-categories` endpoints.
5. `fixed-plans` endpoints. 4. Phase 4: `transactions` endpoints.
6. `income`, `budget`, `payday` endpoints. 5. Phase 5: `fixed-plans` endpoints.
7. `dashboard` + `crisis-status`.
8. `admin` endpoints. Remaining:
1. Phase 6: `income`, `budget`, `payday` endpoints.
2. Phase 7: `dashboard` + `crisis-status`.
3. Phase 8: `admin` + site access endpoints.
4. Phase 9: final cleanup and helper consolidation.
## Remaining Plan (Detailed)
### Phase 5: Fixed Plans Domain
Move these routes out of `server.ts` into `api/src/routes/fixed-plans.ts` (or split module if needed):
1. `PATCH /fixed-plans/:id/early-funding`
2. `POST /fixed-plans/:id/attempt-final-funding`
3. `PATCH /fixed-plans/:id/mark-unpaid`
4. `POST /fixed-plans/:id/fund-from-available`
5. `POST /fixed-plans/:id/catch-up-funding`
6. `POST /fixed-plans`
7. `PATCH /fixed-plans/:id`
8. `DELETE /fixed-plans/:id`
9. `POST /fixed-plans/:id/true-up-actual`
10. `GET /fixed-plans/due`
11. `POST /fixed-plans/:id/pay-now`
Primary risk:
- Payment/funding workflows are tightly coupled with available budget math and rollover rules.
Test focus:
- `api/tests/fixed-plans*.test.ts`
- `api/tests/payment-rollover.test.ts`
### Phase 6: Income, Budget, Payday Domain
Move these routes into a dedicated module (e.g., `api/src/routes/budget-income.ts`):
1. `POST /income`
2. `GET /income/history`
3. `POST /income/preview`
4. `POST /budget/allocate`
5. `POST /budget/fund`
6. `POST /budget/reconcile`
7. `GET /payday/status`
8. `POST /payday/dismiss`
Primary risk:
- Budget-session synchronization and allocator side effects.
Test focus:
- Income/budget allocation tests
- Any tests asserting payday status/dismiss behavior
### Phase 7: Dashboard Read Domain
Move read endpoints into `api/src/routes/dashboard.ts`:
1. `GET /dashboard`
2. `GET /crisis-status`
Primary risk:
- Derived numbers diverging between dashboard and rebalance/session APIs.
Test focus:
- Dashboard API contract checks and UI smoke verification.
### Phase 8: Admin and Site Access Domain
Move operational endpoints into `api/src/routes/admin.ts` and/or `api/src/routes/site-access.ts`:
1. `POST /admin/rollover`
2. `GET /site-access/status`
3. `POST /site-access/unlock`
4. `POST /site-access/lock`
Primary risk:
- Lockout/maintenance flow regressions and accidental open access.
Test focus:
- Site access flow tests
- Admin rollover auth/permission checks
### Phase 9: Final Cleanup
1. Remove dead helper duplicates from `server.ts` and route modules.
2. Consolidate common helpers into `api/src/services/*`.
3. Normalize error envelopes where safe (no contract change unless explicitly planned).
4. Re-run full API/security suites and perform deployment smoke checks.
## Reference Audit Requirement (Per Move)
For every endpoint moved:
1. Record original location in `server.ts`.
2. Record new route module location.
3. Record frontend references (`web/src/api/*`, hooks, pages).
4. Record test references.
5. Add a phase move log under `docs/api-phaseX-move-log.md`.
## Verification Steps (Per Phase)
1. Build:
- `cd api && npm run build`
2. Run focused tests for moved domain.
3. Run security suites relevant to moved endpoints.
4. Deploy.
5. Run production endpoint smoke checks for the moved routes.
6. Confirm logs show expected status codes (no unexplained 401/403/500 shifts).
## Definition of Done per Phase ## Definition of Done per Phase
1. Endpoints compile and register once. 1. Endpoints compile and register once.