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 request from "supertest";
import { randomUUID } from "node:crypto";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import appFactory from "./appFactory";
import { prisma, ensureUser, resetUser, U, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
@@ -16,15 +18,75 @@ beforeEach(async () => {
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
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", () => {
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 () => {
// Create a fixed plan with weekly frequency
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Weekly Subscription",
totalCents: 1000n,
@@ -38,14 +100,13 @@ describe("Payment-Triggered Rollover", () => {
});
// Make payment
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 1000,
planId: plan.id,
isReconciled: true,
});
if (txRes.status !== 200) {
@@ -58,12 +119,19 @@ describe("Payment-Triggered Rollover", () => {
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).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 () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Biweekly Bill",
totalCents: 5000n,
@@ -76,26 +144,32 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 5000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
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 () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Monthly Rent",
totalCents: 100000n,
@@ -108,27 +182,33 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 100000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
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 plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "One-time Expense",
totalCents: 2000n,
@@ -141,28 +221,26 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 2000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
// Due date should remain unchanged for one-time expenses
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
expect(updated).toBeNull();
});
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 plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Manual Bill",
totalCents: 3000n,
@@ -175,27 +253,25 @@ describe("Payment-Triggered Rollover", () => {
},
});
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 3000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(200);
const updated = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });
expect(updated?.fundedCents).toBe(0n);
// Due date should remain unchanged when no frequency
expect(updated?.dueOn.toISOString()).toBe(originalDueDate.toISOString());
expect(updated).toBeNull();
});
it("prevents payment when insufficient funded amount", async () => {
const plan = await prisma.fixedPlan.create({
data: {
id: randomUUID(),
userId: U,
name: "Underfunded Bill",
totalCents: 10000n,
@@ -209,18 +285,17 @@ describe("Payment-Triggered Rollover", () => {
});
// Try to pay more than funded amount
const txRes = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const txRes = await postTransactionsWithCsrf()
.send({
occurredAtISO: "2025-11-27T12:00:00Z",
kind: "fixed_payment",
amountCents: 10000,
planId: plan.id,
isReconciled: true,
});
expect(txRes.status).toBe(400);
expect(txRes.body.code).toBe("OVERDRAFT_PLAN");
expect(txRes.body.code).toBe("INSUFFICIENT_AVAILABLE_BUDGET");
// Plan should remain unchanged
const unchanged = await prisma.fixedPlan.findUnique({ where: { id: plan.id } });

View File

@@ -1,7 +1,8 @@
import request from "supertest";
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { randomUUID } from "node:crypto";
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";
let app: FastifyInstance;
@@ -11,7 +12,9 @@ beforeAll(async () => {
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
await closePrisma();
});
@@ -23,16 +26,22 @@ describe("GET /transactions", () => {
await resetUser(U);
await ensureUser(U);
catId = cid("c");
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({
const category = await prisma.variableCategory.create({
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,
name: "Rent",
totalCents: 10000n,
@@ -43,6 +52,7 @@ describe("GET /transactions", () => {
fundingMode: "auto-on-deposit",
},
});
planId = plan.id;
await prisma.transaction.createMany({
data: [
@@ -100,16 +110,31 @@ describe("POST /transactions", () => {
let catId: 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 () => {
await resetUser(U);
await ensureUser(U);
catId = cid("cat");
planId = pid("plan");
await prisma.variableCategory.create({
const category = await prisma.variableCategory.create({
data: {
id: catId,
id: randomUUID(),
userId: U,
name: "Dining",
percent: 100,
@@ -118,10 +143,11 @@ describe("POST /transactions", () => {
balanceCents: 5000n,
},
});
catId = category.id;
await prisma.fixedPlan.create({
const plan = await prisma.fixedPlan.create({
data: {
id: planId,
id: randomUUID(),
userId: U,
name: "Loan",
totalCents: 10000n,
@@ -132,12 +158,11 @@ describe("POST /transactions", () => {
fundingMode: "auto-on-deposit",
},
});
planId = plan.id;
});
it("spends from a variable category and updates balance", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const res = await postTransactionsWithCsrf()
.send({
kind: "variable_spend",
amountCents: 2000,
@@ -157,10 +182,8 @@ describe("POST /transactions", () => {
expect(tx.isReconciled).toBe(true);
});
it("prevents overdrawing fixed plans", async () => {
const res = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
it("returns insufficient budget when fixed payment requires unavailable top-up", async () => {
const res = await postTransactionsWithCsrf()
.send({
kind: "fixed_payment",
amountCents: 400000, // exceeds funded
@@ -169,13 +192,11 @@ describe("POST /transactions", () => {
});
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 () => {
const created = await request(app.server)
.post("/transactions")
.set("x-user-id", U)
const created = await postTransactionsWithCsrf()
.send({
kind: "variable_spend",
amountCents: 1000,
@@ -185,9 +206,7 @@ describe("POST /transactions", () => {
expect(created.status).toBe(200);
const txId = created.body.id;
const res = await request(app.server)
.patch(`/transactions/${txId}`)
.set("x-user-id", U)
const res = await patchWithCsrf(`/transactions/${txId}`)
.send({
note: "Cleared",
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
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
Current state (2026-03-15):
- `server.ts` has ~4.8k lines and 50 endpoint registrations.
- Duplicate endpoint signatures also exist in `api/src/routes/*` but are not currently registered.
Current state (2026-03-17):
- `server.ts` still holds most business routes, but Phases 1-5 are complete.
- 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
1. Keep route behavior identical while moving code.
@@ -26,7 +31,7 @@ When moving a domain:
4. Confirm no duplicate route registrations remain.
## 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`
- `getUserTimezone(...)`
@@ -49,15 +54,108 @@ Create these helpers first to keep endpoint moves lightweight:
- Standard typed error object and response body builder.
- Keeps endpoint error handling consistent.
## Move Order (Incremental)
1. Low-risk endpoints: `health`, `session`, `me`, `user/config`.
2. `auth` + `account` endpoints.
3. `variable-categories` endpoints.
4. `transactions` endpoints.
5. `fixed-plans` endpoints.
6. `income`, `budget`, `payday` endpoints.
7. `dashboard` + `crisis-status`.
8. `admin` endpoints.
## Progress Snapshot
Completed:
1. Phase 1: low-risk endpoints (`health`, `session`, `me`, `user/config`).
2. Phase 2: `auth` + `account` endpoints.
3. Phase 3: `variable-categories` endpoints.
4. Phase 4: `transactions` endpoints.
5. Phase 5: `fixed-plans` 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
1. Endpoints compile and register once.