diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3999fb5..d430686 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: set -euo pipefail cd api npm ci - npm audit --omit=dev --json > /tmp/skymoney-api-audit.json || true + npm audit --omit=dev --audit-level=high --json > /tmp/skymoney-api-audit.json || true node -e ' const fs = require("fs"); const report = JSON.parse(fs.readFileSync("/tmp/skymoney-api-audit.json", "utf8")); diff --git a/api/src/allocator.ts b/api/src/allocator.ts index bb3d7f1..d300082 100644 --- a/api/src/allocator.ts +++ b/api/src/allocator.ts @@ -778,8 +778,20 @@ async function applyAllocations( ): Promise> { // Fixed plans const planUpdates = new Map(); + const fixedAllocationRows = new Map(); result.fixedAllocations.forEach((a) => { planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents); + const rowKey = `${a.fixedPlanId}:${a.source}`; + const existing = fixedAllocationRows.get(rowKey); + if (existing) { + existing.amountCents += a.amountCents; + return; + } + fixedAllocationRows.set(rowKey, { + planId: a.fixedPlanId, + source: a.source, + amountCents: a.amountCents, + }); }); const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = []; @@ -834,13 +846,19 @@ async function applyAllocations( needsFundingThisPeriod: markFundedThisPeriod ? false : !isFullyFunded, }, }); + } + + for (const row of fixedAllocationRows.values()) { + const rowAmount = Math.max(0, Math.floor(row.amountCents | 0)); + if (rowAmount <= 0) continue; await tx.allocation.create({ data: { userId, kind: "fixed", - toId: planId, - amountCents: BigInt(amt), - incomeId, + toId: row.planId, + amountCents: BigInt(rowAmount), + // Available-budget pulls must not be attributed to the triggering income event. + incomeId: row.source === "income" ? incomeId : null, }, }); } diff --git a/api/tests/allocator.test.ts b/api/tests/allocator.test.ts index d87daba..6f91c7b 100644 --- a/api/tests/allocator.test.ts +++ b/api/tests/allocator.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest"; import { prisma, resetUser, ensureUser, U, cid, pid, closePrisma } from "./helpers"; import { allocateIncome, buildPlanStates } from "../src/allocator"; +const DAY_MS = 86_400_000; + describe("allocator — new funding system", () => { beforeEach(async () => { await resetUser(U); @@ -228,5 +230,58 @@ describe("allocator — new funding system", () => { const sum = variable.reduce((s, a: any) => s + (a.amountCents ?? 0), 0); expect(sum).toBe(0); }); + + it("does not over-attribute crisis available-budget pulls to the triggering income event", async () => { + const c1 = cid("bucket"); + await prisma.variableCategory.create({ + data: { id: c1, userId: U, name: "Bucket", percent: 100, priority: 1, isSavings: false, balanceCents: 0n }, + }); + + const planId = pid("crisis"); + await prisma.fixedPlan.create({ + data: { + id: planId, + userId: U, + name: "Urgent Rent", + cycleStart: new Date(Date.now() - DAY_MS).toISOString(), + dueOn: new Date(Date.now() + 3 * DAY_MS).toISOString(), + totalCents: 40000n, + fundedCents: 0n, + currentFundedCents: 0n, + lastFundingDate: null, + priority: 1, + fundingMode: "auto-on-deposit", + }, + }); + + // Seed existing available budget from prior income that was not fully allocated. + await prisma.incomeEvent.create({ + data: { + id: `seed-${Date.now()}`, + userId: U, + postedAt: new Date(Date.now() - 10 * DAY_MS), + amountCents: 50000n, + isScheduledIncome: false, + }, + }); + + const incomeId = `inc-${Date.now()}`; + const result = await allocateIncome(prisma as any, U, 1000, new Date().toISOString(), incomeId); + expect(result.crisis.pulledFromAvailableCents).toBeGreaterThan(0); + + const linkedToIncome = await prisma.allocation.findMany({ + where: { userId: U, incomeId }, + select: { amountCents: true, kind: true }, + }); + const linkedSum = linkedToIncome.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0); + expect(linkedSum).toBeLessThanOrEqual(1000); + + const unlinkedFixed = await prisma.allocation.findMany({ + where: { userId: U, incomeId: null, kind: "fixed" }, + select: { amountCents: true }, + }); + const unlinkedFixedSum = unlinkedFixed.reduce((sum, row) => sum + Number(row.amountCents ?? 0n), 0); + expect(unlinkedFixedSum).toBeGreaterThan(0); + }); }); });