Files
SkyMoney/api/src/allocator.ts
Ricearoni1245 47bc092da1
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Security Tests / security-non-db (push) Successful in 23s
Security Tests / security-db (push) Successful in 33s
first attempt at fixing over-allocation bug; fix npm audit block for deploy
2026-04-02 22:06:35 -05:00

1685 lines
55 KiB
TypeScript

import type {
PrismaClient,
Prisma,
IncomeFrequency,
} from "@prisma/client";
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
import { addDays } from "date-fns";
const DAY_MS = 86_400_000;
/**
* Get the current date/time in the user's timezone, normalized to start of day
* @exported for use in server.ts and other modules
*/
export function getUserMidnight(timezone: string, date: Date = new Date()): Date {
// Convert to user's timezone
const zonedDate = toZonedTime(date, timezone);
// Set to midnight in their timezone
zonedDate.setHours(0, 0, 0, 0);
// Convert back to UTC for storage/comparison
return fromZonedTime(zonedDate, timezone);
}
export function getUserMidnightFromDateOnly(timezone: string, date: Date): Date {
const zoned = toZonedTime(date, timezone);
zoned.setHours(0, 0, 0, 0);
return fromZonedTime(zoned, timezone);
}
export function getUserDateRangeFromDateOnly(
timezone: string,
from?: string,
to?: string
): { gte?: Date; lt?: Date } {
const range: { gte?: Date; lt?: Date } = {};
if (from) {
range.gte = getUserMidnightFromDateOnly(timezone, new Date(from));
}
if (to) {
const endDate = getUserMidnightFromDateOnly(timezone, new Date(to));
range.lt = addDays(endDate, 1);
}
return range;
}
type FixedAllocation = { fixedPlanId: string; amountCents: number; source: "income" | "available" };
type VariableAllocation = { variableCategoryId: string; amountCents: number };
/**
* Calculate the next expected payday based on first income date and frequency
*/
export function calculateNextPayday(
firstIncomeDate: Date,
frequency: IncomeFrequency,
fromDate: Date = new Date(),
timezone: string = 'UTC'
): Date {
// Normalize dates to user's midnight for date-only comparison
const normalizedFrom = getUserMidnight(timezone, fromDate);
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
// Get the target day in the USER'S timezone, not server local time
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
if (process.env.NODE_ENV !== "production") {
console.log(`🗓️ calculateNextPayday:`, {
firstIncomeDate: firstIncomeDate.toISOString(),
frequency,
fromDate: fromDate.toISOString(),
normalizedFrom: normalizedFrom.toISOString(),
startingNextPayDate: nextPayDate.toISOString(),
targetDay,
timezone,
});
}
// Advance to the next pay date on or after fromDate
let iterations = 0;
while (nextPayDate < normalizedFrom) {
if (frequency === 'monthly') {
// For monthly: advance by actual month, preserving day of month
// Work in user's timezone to avoid date boundary issues
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
// Handle months with fewer days (e.g., Jan 31 -> Feb 28)
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
// Convert back to UTC
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
// For weekly/biweekly: advance by fixed days (timezone-safe since we're adding days)
const freqDays = frequencyDays[frequency];
nextPayDate.setDate(nextPayDate.getDate() + freqDays);
}
iterations++;
}
if (process.env.NODE_ENV !== "production") {
console.log(`🗓️ calculateNextPayday result:`, {
iterations,
nextPayDate: nextPayDate.toISOString(),
isToday: nextPayDate.getTime() === normalizedFrom.getTime(),
});
}
return nextPayDate;
}
/**
* Check if a date is within the payday window (±1 day)
*/
export function isWithinPaydayWindow(
date: Date,
expectedPayday: Date,
windowDays: number = 1,
timezone: string = 'UTC'
): boolean {
// Normalize both dates to user's midnight for date-only comparison
const normalizedDate = getUserMidnight(timezone, date);
const normalizedPayday = getUserMidnight(timezone, expectedPayday);
const diffMs = Math.abs(normalizedDate.getTime() - normalizedPayday.getTime());
const diffDays = diffMs / DAY_MS;
const isWithin = diffDays <= windowDays;
if (process.env.NODE_ENV !== "production") {
console.log(`📍 isWithinPaydayWindow:`, {
date: date.toISOString(),
normalizedDate: normalizedDate.toISOString(),
expectedPayday: expectedPayday.toISOString(),
normalizedPayday: normalizedPayday.toISOString(),
diffMs,
diffDays,
windowDays,
isWithin,
});
}
return isWithin;
}
type CrisisPlan = {
id: string;
name: string;
remainingCents: number;
daysUntilDue: number;
priority: number;
allocatedCents: number;
};
type PlanState = {
id: string;
name: string;
totalCents: number;
fundedCents: number;
dueOn: Date;
priority: number;
remainingCents: number;
daysUntilDue: number;
desiredThisIncome: number;
isCrisis: boolean;
allocatedThisRun: number;
needsFundingThisPeriod: boolean;
hasPaymentSchedule: boolean;
autoFundEnabled: boolean;
isOverdue: boolean;
overdueAmount: number;
overdueSince: Date | null;
};
type AllocationComputation = {
fixedAllocations: FixedAllocation[];
variableAllocations: VariableAllocation[];
remainingUnallocatedCents: number;
availableBudgetAfterCents: number;
crisis: {
active: boolean;
pulledFromAvailableCents: number;
plans: CrisisPlan[];
};
planStatesAfter: PlanState[];
overduePaid?: {
totalAmount: number;
plans: Array<{ id: string; name: string; amountPaid: number }>;
};
};
const frequencyDays: Record<IncomeFrequency, number> = {
weekly: 7,
biweekly: 14,
monthly: 30,
};
/**
* Count the number of pay periods between two dates based on the recurring pattern.
* For weekly/biweekly: counts occurrences of the specific day of week.
* For monthly: counts occurrences of the specific day of month.
* All dates are interpreted in the user's timezone.
*/
export function countPayPeriodsBetween(
startDate: Date,
endDate: Date,
firstIncomeDate: Date,
frequency: IncomeFrequency,
timezone: string = 'UTC'
): number {
let count = 0;
// Normalize to user's timezone
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
const normalizedStart = getUserMidnight(timezone, startDate);
const normalizedEnd = getUserMidnight(timezone, endDate);
// Get the target day in the USER'S timezone
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
// Helper to advance date by one period
const advanceByPeriod = () => {
if (frequency === 'monthly') {
// Work in user's timezone for month advancement
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
};
// Advance to the first pay date on or after startDate
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
// Count all pay dates up to (but not including) the end date
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
// Ensure at least 1 period to avoid division by zero
return Math.max(1, count);
}
async function getAvailableBudgetCents(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
): Promise<number> {
const [incomeAgg, allocAgg] = await Promise.all([
tx.incomeEvent.aggregate({ where: { userId }, _sum: { amountCents: true } }),
tx.allocation.aggregate({ where: { userId }, _sum: { amountCents: true } }),
]);
const income = Number(incomeAgg._sum?.amountCents ?? 0n);
const allocated = Number(allocAgg._sum?.amountCents ?? 0n);
return Math.max(0, income - allocated);
}
async function getUserConfig(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
): Promise<{ incomeFrequency: IncomeFrequency; firstIncomeDate: Date | null; timezone: string }> {
const user = await tx.user.findUnique({
where: { id: userId },
select: {
incomeFrequency: true,
firstIncomeDate: true,
timezone: true,
},
});
return {
incomeFrequency: user?.incomeFrequency ?? "biweekly",
firstIncomeDate: user?.firstIncomeDate ?? null,
timezone: user?.timezone ?? "America/New_York",
};
}
async function getInputs(
tx: PrismaClient | Prisma.TransactionClient,
userId: string
) {
const [plans, cats, config, availableBefore, user] = await Promise.all([
tx.fixedPlan.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { dueOn: "asc" }],
select: {
id: true,
name: true,
totalCents: true,
fundedCents: true,
currentFundedCents: true,
dueOn: true,
priority: true,
fundingMode: true,
needsFundingThisPeriod: true,
paymentSchedule: true,
autoPayEnabled: true,
isOverdue: true,
overdueAmount: true,
overdueSince: true,
},
}),
tx.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, priority: true, balanceCents: true },
}),
getUserConfig(tx, userId),
getAvailableBudgetCents(tx, userId),
tx.user.findUnique({
where: { id: userId },
select: { incomeType: true },
}),
]);
return { plans, cats, config, availableBefore, user };
}
export function buildPlanStates(
plans: Awaited<ReturnType<typeof getInputs>>["plans"],
config: { incomeFrequency: IncomeFrequency; firstIncomeDate: Date | null; timezone: string },
now: Date,
userIncomeType?: string,
isScheduledIncome?: boolean
): PlanState[] {
const timezone = config.timezone ?? "UTC";
const firstIncomeDate = config.firstIncomeDate ?? null;
const freqDays = frequencyDays[config.incomeFrequency];
// Only handle regular income frequencies
if (!freqDays) {
throw new Error(`Unsupported income frequency: ${config.incomeFrequency}`);
}
return plans.map((p) => {
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n);
const total = Number(p.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
// Calculate preliminary crisis status to determine if we should override funding restrictions
// Use timezone-aware date comparison
const userNow = getUserMidnight(timezone, now);
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
const daysUntilDuePrelim = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const fundedPercent = total > 0 ? (funded / total) * 100 : 100;
const CRISIS_MINIMUM_CENTS = 1000; // $10 minimum
// Check if this is a crisis situation (quick check before full calculation)
const isPaymentPlanUser = userIncomeType === "regular" && hasPaymentSchedule;
let isPrelimCrisis = false;
let dueBeforeNextPayday = false;
let daysUntilPayday = 0;
if (isPaymentPlanUser && firstIncomeDate) {
const nextPayday = calculateNextPayday(firstIncomeDate, config.incomeFrequency, now, timezone);
const normalizedNextPayday = getUserMidnight(timezone, nextPayday);
daysUntilPayday = Math.max(0, Math.ceil((normalizedNextPayday.getTime() - userNow.getTime()) / DAY_MS));
dueBeforeNextPayday = userDueDate.getTime() < normalizedNextPayday.getTime();
}
if (remainingCents >= CRISIS_MINIMUM_CENTS) {
if (isPaymentPlanUser && firstIncomeDate) {
isPrelimCrisis = daysUntilDuePrelim < daysUntilPayday && fundedPercent < 90;
} else {
isPrelimCrisis = fundedPercent < 70 && daysUntilDuePrelim <= 14;
}
}
// Phase 2: For regular users with payment plans, only fund if:
// 1. needsFundingThisPeriod is true, AND
// 2. This is a scheduled income (regular paycheck) OR it's crisis mode (always fund crisis)
const shouldSkipFunding = isPaymentPlanUser &&
!isPrelimCrisis && // Crisis overrides funding restrictions
!dueBeforeNextPayday && // Fund now if due before next payday
(!needsFundingThisPeriod || (needsFundingThisPeriod && !isScheduledIncome));
// If auto-fund is disabled, do not allocate from income (unless overdue handling already did).
if (!autoFundEnabled && !p.isOverdue) {
return {
id: p.id,
name: p.name,
totalCents: total,
fundedCents: funded,
dueOn: p.dueOn,
priority: p.priority,
remainingCents,
daysUntilDue: daysUntilDuePrelim,
desiredThisIncome: 0,
isCrisis: false,
allocatedThisRun: 0,
needsFundingThisPeriod,
hasPaymentSchedule,
autoFundEnabled,
isOverdue: p.isOverdue ?? false,
overdueAmount: Number(p.overdueAmount ?? 0n),
overdueSince: p.overdueSince ?? null,
};
}
// If plan is fully funded OR should skip funding, return zero-allocation state
if (remainingCents === 0 || shouldSkipFunding) {
return {
id: p.id,
name: p.name,
totalCents: total,
fundedCents: funded,
dueOn: p.dueOn,
priority: p.priority,
remainingCents: shouldSkipFunding ? remainingCents : 0,
daysUntilDue: 0,
desiredThisIncome: 0,
isCrisis: false,
allocatedThisRun: 0,
needsFundingThisPeriod,
hasPaymentSchedule,
autoFundEnabled,
isOverdue: p.isOverdue ?? false,
overdueAmount: Number(p.overdueAmount ?? 0n),
overdueSince: p.overdueSince ?? null,
};
}
// Use timezone-aware calculation for daysUntilDue
const daysUntilDue = daysUntilDuePrelim; // Already calculated with timezone
// Calculate payment periods more accurately using firstIncomeDate
let cyclesLeft: number;
if (firstIncomeDate) {
// Count actual pay dates between now and due date based on the recurring pattern
// established by firstIncomeDate (pass timezone for correct date handling)
cyclesLeft = countPayPeriodsBetween(userNow, userDueDate, firstIncomeDate, config.incomeFrequency, timezone);
} else {
// Fallback to old calculation if firstIncomeDate not set
cyclesLeft = Math.max(1, Math.ceil(daysUntilDue / freqDays));
}
const perIncome = Math.ceil(remainingCents / cyclesLeft);
const desiredThisIncome = Math.min(remainingCents, perIncome);
// Determine crisis status based on user type and funding situation (reuse from preliminary check)
const isCrisis = isPrelimCrisis;
return {
id: p.id,
name: p.name,
totalCents: total,
fundedCents: funded,
dueOn: p.dueOn,
priority: p.priority,
remainingCents,
daysUntilDue,
desiredThisIncome,
isCrisis,
allocatedThisRun: 0,
needsFundingThisPeriod,
hasPaymentSchedule,
autoFundEnabled,
isOverdue: p.isOverdue ?? false,
overdueAmount: Number(p.overdueAmount ?? 0n),
overdueSince: p.overdueSince ?? null,
};
});
}
function distributeToFixed(
plans: PlanState[],
incomePool: number,
availablePool: number
): {
allocations: FixedAllocation[];
updatedPlans: PlanState[];
incomeLeft: number;
availableLeft: number;
crisis: CrisisPlan[];
pulledFromAvailable: number;
} {
const allocations: FixedAllocation[] = [];
const updatedPlans = plans.map((p) => ({ ...p }));
let incomeLeft = incomePool;
let availableLeft = availablePool;
let pulledFromAvailable = 0;
// PRIORITY 1: Overdue bills (oldest first)
const overduePlans = updatedPlans
.filter((p) => p.isOverdue && p.overdueAmount > 0)
.sort((a, b) => {
// Sort by overdue date (oldest first - FIFO)
if (a.overdueSince && b.overdueSince) {
const aTime = a.overdueSince.getTime();
const bTime = b.overdueSince.getTime();
if (aTime !== bTime) return aTime - bTime;
}
// Fallback to priority if same overdue date
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
// Fund overdue bills FIRST with ALL available income
for (const plan of overduePlans) {
const target = plan.overdueAmount;
let allocated = 0;
const fromIncome = Math.min(incomeLeft, target);
incomeLeft -= fromIncome;
allocated += fromIncome;
// Overdue bills get ALL income until fully paid
if (allocated > 0) {
allocations.push({ fixedPlanId: plan.id, amountCents: fromIncome, source: "income" });
plan.remainingCents = Math.max(0, plan.remainingCents - allocated);
plan.fundedCents += allocated;
plan.allocatedThisRun += allocated;
}
}
// PRIORITY 2: Crisis plans (only after overdue is handled)
const crisisCandidates = updatedPlans
.filter((p) => !p.isOverdue && p.autoFundEnabled && p.isCrisis && p.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
for (const plan of crisisCandidates) {
const target = plan.remainingCents;
let allocated = 0;
const fromIncome = Math.min(incomeLeft, target);
incomeLeft -= fromIncome;
allocated += fromIncome;
const stillNeed = target - allocated;
let fromAvailable = 0;
if (stillNeed > 0 && availableLeft > 0) {
fromAvailable = Math.min(availableLeft, stillNeed);
availableLeft -= fromAvailable;
allocated += fromAvailable;
pulledFromAvailable += fromAvailable;
}
if (allocated > 0) {
if (fromIncome > 0) allocations.push({ fixedPlanId: plan.id, amountCents: fromIncome, source: "income" });
if (fromAvailable > 0) allocations.push({ fixedPlanId: plan.id, amountCents: fromAvailable, source: "available" });
plan.remainingCents = Math.max(0, plan.remainingCents - allocated);
plan.fundedCents += allocated;
plan.allocatedThisRun += allocated;
}
}
// PRIORITY 3: Regular plans (not overdue, not crisis)
const activePlans = updatedPlans.filter((p) => !p.isOverdue && p.autoFundEnabled && p.remainingCents > 0 && !p.isCrisis);
const totalDesired = activePlans.reduce((sum, p) => sum + Math.min(p.desiredThisIncome, p.remainingCents), 0);
if (incomeLeft > 0 && totalDesired > 0) {
const base = new Array(activePlans.length).fill(0);
const tie: Array<{ idx: number; remainder: number; priority: number; days: number; name: string }> = [];
let sumBase = 0;
activePlans.forEach((p, idx) => {
const desired = Math.min(p.desiredThisIncome, p.remainingCents);
const exact = (incomeLeft * desired) / totalDesired;
const floor = Math.floor(exact);
// Cap the allocation at the desired amount to prevent over-allocation
base[idx] = Math.min(floor, desired);
sumBase += base[idx];
tie.push({ idx, remainder: exact - floor, priority: p.priority, days: p.daysUntilDue, name: p.name });
});
let leftovers = incomeLeft - sumBase;
tie.sort((a, b) => {
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.days !== b.days) return a.days - b.days;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) {
const planIdx = tie[i].idx;
const plan = activePlans[planIdx];
const desired = Math.min(plan.desiredThisIncome, plan.remainingCents);
// Only add leftover if we haven't reached the desired amount
if (base[planIdx] < desired) {
base[planIdx]++;
}
}
activePlans.forEach((p, idx) => {
const give = base[idx] || 0;
if (give <= 0) return;
allocations.push({ fixedPlanId: p.id, amountCents: give, source: "income" });
p.remainingCents = Math.max(0, p.remainingCents - give);
p.fundedCents += give;
p.allocatedThisRun += give;
incomeLeft -= give;
});
}
const crisisReport: CrisisPlan[] = crisisCandidates.map((p) => ({
id: p.id,
name: p.name,
remainingCents: Math.max(0, p.remainingCents),
daysUntilDue: p.daysUntilDue,
priority: p.priority,
allocatedCents: p.allocatedThisRun,
}));
return {
allocations,
updatedPlans,
incomeLeft,
availableLeft,
crisis: crisisReport,
pulledFromAvailable,
};
}
function allocateVariables(
cats: Awaited<ReturnType<typeof getInputs>>["cats"],
incomePool: number
): { allocations: VariableAllocation[]; remainingIncome: number } {
if (incomePool <= 0 || cats.length === 0) {
return { allocations: [], remainingIncome: incomePool };
}
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
if (totalPercent <= 0) {
return { allocations: [], remainingIncome: incomePool };
}
// Step 1: Handle negative balances first (deficit recovery)
let remainingPool = incomePool;
const deficitRecovery: { id: string; amount: number }[] = [];
cats.forEach((c) => {
const currentBalance = Number(c.balanceCents);
if (currentBalance < 0 && remainingPool > 0) {
const deficitAmount = Math.min(Math.abs(currentBalance), remainingPool);
deficitRecovery.push({ id: c.id, amount: deficitAmount });
remainingPool -= deficitAmount;
}
});
// Step 2: Distribute remaining pool by percentages with balance awareness
const norm = cats.map((c) => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
currentBalance: Number(c.balanceCents),
}));
const base: number[] = new Array(norm.length).fill(0);
const tie: Array<{
idx: number;
remainder: number;
isSavings: boolean;
priority: number;
name: string;
needsBoost: boolean;
}> = [];
let sumBase = 0;
// Calculate base allocations with balance consideration
norm.forEach((c, idx) => {
// Add any deficit recovery first
const deficitAmount = deficitRecovery.find(d => d.id === c.id)?.amount || 0;
// Calculate percentage-based allocation
const percentageAmount = Math.floor((remainingPool * (c.percent || 0)) / 100);
const totalAmount = deficitAmount + percentageAmount;
base[idx] = totalAmount;
sumBase += totalAmount;
// Track remainder and whether this category needs a boost
const exact = (remainingPool * (c.percent || 0)) / 100;
const remainder = exact - percentageAmount;
// Categories with very low balances relative to their percentage get priority
const expectedBalance = (incomePool * (c.percent || 0)) / 200; // Half of ideal allocation as threshold
const needsBoost = c.currentBalance + deficitAmount < expectedBalance;
tie.push({
idx,
remainder,
isSavings: !!c.isSavings,
priority: c.priority,
name: c.name,
needsBoost
});
});
// Step 3: Distribute any remaining cents intelligently
let leftovers = incomePool - sumBase;
tie.sort((a, b) => {
// Priority order: categories that need boost > savings > remainder amount > priority > name
if (a.needsBoost !== b.needsBoost) return a.needsBoost ? -1 : 1;
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) {
base[tie[i].idx]++;
}
// Step 4: Create final allocations
const allocations: VariableAllocation[] = [];
norm.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) allocations.push({ variableCategoryId: c.id, amountCents: give });
});
return { allocations, remainingIncome: leftovers };
}
function computeAllocation(
inputs: Awaited<ReturnType<typeof getInputs>>,
amountCents: number,
postedAt: Date,
isScheduledIncome: boolean = false
): AllocationComputation {
const amt = Math.max(0, Math.floor(amountCents | 0));
const incomePoolStart = amt;
let incomePool = amt;
let availablePool = inputs.availableBefore;
const planStates = buildPlanStates(inputs.plans, inputs.config, postedAt, inputs.user?.incomeType, isScheduledIncome);
const {
allocations: fixedAllocations,
updatedPlans,
incomeLeft,
availableLeft,
crisis,
pulledFromAvailable,
} = distributeToFixed(planStates, incomePool, availablePool);
incomePool = incomeLeft;
availablePool = availableLeft;
const { allocations: variableAllocations, remainingIncome } = allocateVariables(inputs.cats, incomePool);
incomePool = remainingIncome;
const totalAllocated =
fixedAllocations.reduce((s, a) => s + a.amountCents, 0) +
variableAllocations.reduce((s, a) => s + a.amountCents, 0);
const availableBudgetAfterCents = Math.max(0, inputs.availableBefore + incomePoolStart - totalAllocated);
return {
fixedAllocations,
variableAllocations,
remainingUnallocatedCents: Math.max(0, incomePool),
availableBudgetAfterCents,
crisis: {
active: crisis.some((p) => p.remainingCents > 0 && p.daysUntilDue <= 7),
pulledFromAvailableCents: pulledFromAvailable,
plans: crisis,
},
planStatesAfter: updatedPlans,
};
}
async function applyAllocations(
tx: Prisma.TransactionClient,
userId: string,
incomeId: string,
postedAt: Date,
result: AllocationComputation,
markFundedThisPeriod: boolean
): Promise<Array<{id: string, name: string, dueOn: Date}>> {
// Fixed plans
const planUpdates = new Map<string, number>();
const fixedAllocationRows = new Map<string, { planId: string; source: FixedAllocation["source"]; amountCents: number }>();
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}> = [];
for (const [planId, amount] of planUpdates) {
const amt = Math.max(0, Math.floor(amount | 0));
if (amt <= 0) continue;
// Check if plan will be fully funded after this allocation
const plan = await tx.fixedPlan.findUnique({
where: { id: planId },
select: {
name: true,
totalCents: true,
currentFundedCents: true,
fundedCents: true,
dueOn: true,
frequency: true
},
});
const currentFunded = Number(plan?.currentFundedCents ?? plan?.fundedCents ?? 0n);
const total = Number(plan?.totalCents ?? 0n);
const newFunded = currentFunded + amt;
const isFullyFunded = newFunded >= total;
// Track plans that just reached 100% and are recurring
if (isFullyFunded && plan?.frequency && plan.frequency !== "one-time") {
const today = new Date(postedAt);
today.setHours(0, 0, 0, 0);
const dueDate = new Date(plan.dueOn);
dueDate.setHours(0, 0, 0, 0);
// Only show modal if due date is in the future (not today)
if (dueDate > today) {
fullyFundedPlans.push({
id: planId,
name: plan.name,
dueOn: plan.dueOn
});
}
}
await tx.fixedPlan.update({
where: { id: planId },
data: {
fundedCents: { increment: BigInt(amt) },
currentFundedCents: { increment: BigInt(amt) },
lastFundingDate: postedAt,
lastFundedPayPeriod: postedAt,
// Mark the plan funded for this pay period when using a scheduled income
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: row.planId,
amountCents: BigInt(rowAmount),
// Available-budget pulls must not be attributed to the triggering income event.
incomeId: row.source === "income" ? incomeId : null,
},
});
}
// Variable categories
for (const alloc of result.variableAllocations) {
const amt = Math.max(0, Math.floor(alloc.amountCents | 0));
if (amt <= 0) continue;
await tx.variableCategory.update({
where: { id: alloc.variableCategoryId },
data: { balanceCents: { increment: BigInt(amt) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: alloc.variableCategoryId,
amountCents: BigInt(amt),
incomeId,
},
});
}
return fullyFundedPlans;
}
export async function previewAllocation(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO?: string,
isScheduledIncome: boolean = false
) {
const postedAt = postedAtISO ? new Date(postedAtISO) : new Date();
const inputs = await getInputs(db, userId);
return computeAllocation(inputs, amountCents, postedAt, isScheduledIncome);
}
export async function previewIrregularAllocation(
db: PrismaClient,
userId: string,
amountCents: number,
fixedExpensePercentage: number,
postedAtISO?: string
) {
const postedAt = postedAtISO ? new Date(postedAtISO) : new Date();
const inputs = await getInputs(db, userId);
return computeBudgetAllocation(inputs, amountCents, fixedExpensePercentage, postedAt);
}
// Helper to check if we should reset funding flags for a new pay period
async function checkAndResetPayPeriodFlags(
tx: PrismaClient | Prisma.TransactionClient,
userId: string,
postedAt: Date
) {
const user = await tx.user.findUnique({
where: { id: userId },
select: { incomeFrequency: true, incomeType: true },
});
// Only for regular income users
if (user?.incomeType !== "regular") return;
const frequency = user.incomeFrequency;
const daysPerPeriod = frequencyDays[frequency];
// Get the most recent income event before this one
const lastIncome = await tx.incomeEvent.findFirst({
where: {
userId,
postedAt: { lt: postedAt },
},
orderBy: { postedAt: "desc" },
});
// If this is the first income, or if enough days have passed for a new period
if (!lastIncome) return;
const daysSinceLastIncome = Math.floor((postedAt.getTime() - lastIncome.postedAt.getTime()) / DAY_MS);
// If we've crossed into a new pay period, reset the flags
if (daysSinceLastIncome >= daysPerPeriod) {
await tx.fixedPlan.updateMany({
where: {
userId,
needsFundingThisPeriod: false,
},
data: {
needsFundingThisPeriod: true,
},
});
}
}
/**
* Process overdue bills first when new income arrives
* Returns the amount paid to overdue bills and the remaining income
*/
async function processOverdueBills(
tx: Prisma.TransactionClient,
userId: string,
incomeId: string,
availableIncome: number,
postedAt: Date
): Promise<{ totalPaidToOverdue: number; overduePlansPaid: Array<{ id: string; name: string; amountPaid: number }>; remainingIncome: number }> {
// Query all overdue bills, ordered by oldest first
const overduePlans = await tx.fixedPlan.findMany({
where: {
userId,
isOverdue: true,
overdueAmount: { gt: 0 },
},
orderBy: {
overdueSince: 'asc', // Pay oldest overdue bills first
},
select: {
id: true,
name: true,
overdueAmount: true,
fundedCents: true,
currentFundedCents: true,
},
});
if (overduePlans.length === 0) {
return { totalPaidToOverdue: 0, overduePlansPaid: [], remainingIncome: availableIncome };
}
let remaining = availableIncome;
let totalPaid = 0;
const paidPlans: Array<{ id: string; name: string; amountPaid: number }> = [];
for (const plan of overduePlans) {
if (remaining <= 0) break;
const overdueAmount = Number(plan.overdueAmount);
const amountToPay = Math.min(overdueAmount, remaining);
if (amountToPay > 0) {
// Create allocation record for tracking
await tx.allocation.create({
data: {
userId,
kind: 'fixed',
toId: plan.id,
amountCents: BigInt(amountToPay),
incomeId,
},
});
// Update the plan: add to funded amounts and reduce overdue
const newOverdueAmount = overdueAmount - amountToPay;
const newFundedCents = (plan.fundedCents ?? 0n) + BigInt(amountToPay);
const newCurrentFundedCents = (plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amountToPay);
await tx.fixedPlan.update({
where: { id: plan.id },
data: {
fundedCents: newFundedCents,
currentFundedCents: newCurrentFundedCents,
overdueAmount: newOverdueAmount,
isOverdue: newOverdueAmount > 0, // Clear flag if fully paid
lastFundingDate: postedAt,
},
});
totalPaid += amountToPay;
remaining -= amountToPay;
paidPlans.push({ id: plan.id, name: plan.name, amountPaid: amountToPay });
}
}
return {
totalPaidToOverdue: totalPaid,
overduePlansPaid: paidPlans,
remainingIncome: remaining,
};
}
export async function allocateIncome(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO: string,
incomeId: string,
note?: string | null,
isScheduledIncome: boolean = false
): Promise<AllocationComputation> {
const postedAt = new Date(postedAtISO);
const amt = Math.max(0, Math.floor(amountCents | 0));
return await db.$transaction(async (tx) => {
// Check if this is the user's first income event
const previousIncomeCount = await tx.incomeEvent.count({
where: { userId },
});
const isFirstIncome = previousIncomeCount === 0;
// Check if we need to reset pay period flags
await checkAndResetPayPeriodFlags(tx, userId, postedAt);
const [inputs, userFlags] = await Promise.all([
getInputs(tx, userId),
tx.user.findUnique({
where: { id: userId },
select: { pendingScheduledIncome: true, incomeType: true, firstIncomeDate: true, incomeFrequency: true, timezone: true, fixedExpensePercentage: true },
}),
]);
const isRegularUser = userFlags?.incomeType === "regular";
if (!isRegularUser) {
const fixedExpensePercentage = userFlags?.fixedExpensePercentage ?? 40;
const irregularResult = await applyIrregularIncomeInTx(
tx as Prisma.TransactionClient,
userId,
amt,
fixedExpensePercentage,
postedAt,
incomeId,
note
);
return {
...mapBudgetResultToAllocation(irregularResult),
fullyFundedPlans: [],
overduePaid: { totalAmount: 0, plans: [] },
};
}
const hasPendingScheduledIncome =
!!userFlags?.pendingScheduledIncome && isRegularUser;
let isPayday = false;
if (isRegularUser && userFlags?.firstIncomeDate && userFlags?.incomeFrequency) {
const userTz = userFlags?.timezone || inputs.config.timezone || "America/New_York";
const nextPayday = calculateNextPayday(
userFlags.firstIncomeDate,
userFlags.incomeFrequency,
postedAt,
userTz
);
isPayday = isWithinPaydayWindow(postedAt, nextPayday, 0, userTz);
}
const finalIsScheduledIncome =
isScheduledIncome || hasPendingScheduledIncome || (isRegularUser && isPayday && !hasPendingScheduledIncome);
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {},
create: {
id: incomeId,
userId,
postedAt,
amountCents: BigInt(amt),
note: note ?? null,
isScheduledIncome: finalIsScheduledIncome,
},
});
// Clear pendingScheduledIncome flag if this is a scheduled income
if (finalIsScheduledIncome) {
await tx.user.update({
where: { id: userId },
data: { pendingScheduledIncome: false },
});
}
// STEP 1: Pay overdue bills first
const overdueResult = await processOverdueBills(tx, userId, incomeId, amt, postedAt);
// STEP 2: Re-fetch inputs after overdue payments to get updated plan states
const updatedInputs = overdueResult.totalPaidToOverdue > 0
? await getInputs(tx, userId)
: inputs;
// STEP 3: Allocate remaining income normally
// For first income, treat it as scheduled income to fund payment plans during onboarding
const effectiveIsScheduledIncome = isFirstIncome || finalIsScheduledIncome;
const result = computeAllocation(updatedInputs, overdueResult.remainingIncome, postedAt, effectiveIsScheduledIncome);
const fullyFundedPlans = await applyAllocations(
tx as Prisma.TransactionClient,
userId,
incomeId,
postedAt,
result,
effectiveIsScheduledIncome
);
return {
...result,
fullyFundedPlans,
// Add overdue payment info to result
overduePaid: {
totalAmount: overdueResult.totalPaidToOverdue,
plans: overdueResult.overduePlansPaid,
},
};
});
}
function aggregateOverrides(overrides: Array<{ id: string; amountCents: number }>) {
const map = new Map<string, number>();
for (const { id, amountCents } of overrides) {
if (!id) continue;
const amt = Math.max(0, Math.floor(amountCents | 0));
if (amt <= 0) continue;
map.set(id, (map.get(id) ?? 0) + amt);
}
return map;
}
export async function allocateIncomeManual(
db: PrismaClient,
userId: string,
amountCents: number,
postedAtISO: string,
incomeId: string,
overrides: Array<{ type: "fixed" | "variable"; id: string; amountCents: number }>,
note?: string | null
): Promise<AllocationComputation> {
const amt = Math.max(0, Math.floor(amountCents | 0));
const postedAt = new Date(postedAtISO);
const overrideTotal = overrides.reduce((sum, o) => sum + Math.max(0, Math.floor(o.amountCents | 0)), 0);
if (overrideTotal > amt) {
const err: any = new Error("Override amounts exceed deposit");
err.statusCode = 400;
err.code = "OVERRIDE_EXCEEDS_AMOUNT";
throw err;
}
const fixedMap = aggregateOverrides(
overrides.filter((o) => o.type === "fixed").map((o) => ({ id: o.id, amountCents: o.amountCents }))
);
const variableMap = aggregateOverrides(
overrides.filter((o) => o.type === "variable").map((o) => ({ id: o.id, amountCents: o.amountCents }))
);
return await db.$transaction(async (tx) => {
const inputsBefore = await getInputs(tx, userId);
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {},
create: {
id: incomeId,
userId,
postedAt,
amountCents: BigInt(amt),
note: note ?? null,
},
});
if (fixedMap.size > 0) {
const plans = await tx.fixedPlan.findMany({
where: { userId, id: { in: Array.from(fixedMap.keys()) } },
select: { id: true, fundedCents: true, currentFundedCents: true, totalCents: true },
});
const planById = new Map(plans.map((p) => [p.id, p]));
for (const [planId, amount] of fixedMap) {
const plan = planById.get(planId);
if (!plan) {
const err: any = new Error(`Unknown plan ${planId}`);
err.statusCode = 400;
err.code = "PLAN_NOT_FOUND";
throw err;
}
const funded = Number(plan.currentFundedCents ?? plan.fundedCents ?? 0n);
const total = Number(plan.totalCents ?? 0n);
const need = Math.max(0, total - funded);
if (amount > need) {
const err: any = new Error(`Amount exceeds remaining need for plan ${planId}`);
err.statusCode = 400;
err.code = "PLAN_OVERFUND";
throw err;
}
const fundedValue = (plan.fundedCents ?? 0n) + BigInt(amount);
const currentFundedValue =
(plan.currentFundedCents ?? plan.fundedCents ?? 0n) + BigInt(amount);
await tx.fixedPlan.update({
where: { id: planId },
data: {
fundedCents: fundedValue,
currentFundedCents: currentFundedValue,
lastFundingDate: postedAt,
},
});
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: planId,
amountCents: BigInt(amount),
incomeId,
},
});
}
}
if (variableMap.size > 0) {
const categories = await tx.variableCategory.findMany({
where: { userId, id: { in: Array.from(variableMap.keys()) } },
select: { id: true },
});
const categorySet = new Set(categories.map((c) => c.id));
for (const [catId, amount] of variableMap) {
if (!categorySet.has(catId)) {
const err: any = new Error(`Unknown category ${catId}`);
err.statusCode = 400;
err.code = "CATEGORY_NOT_FOUND";
throw err;
}
await tx.variableCategory.update({
where: { id: catId },
data: { balanceCents: { increment: BigInt(amount) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: catId,
amountCents: BigInt(amount),
incomeId,
},
});
}
}
const manualFixedAllocations = Array.from(fixedMap.entries()).map(([fixedPlanId, amountCents]) => ({
fixedPlanId,
amountCents,
source: "income" as const,
}));
const manualVariableAllocations = Array.from(variableMap.entries()).map(
([variableCategoryId, amountCents]) => ({
variableCategoryId,
amountCents,
})
);
const remainingBeforeAuto = Math.max(0, amt - overrideTotal);
if (remainingBeforeAuto <= 0) {
const inputs = await getInputs(tx, userId);
return {
fixedAllocations: manualFixedAllocations,
variableAllocations: manualVariableAllocations,
remainingUnallocatedCents: 0,
availableBudgetAfterCents: Math.max(0, inputsBefore.availableBefore + amt - overrideTotal),
crisis: { active: false, pulledFromAvailableCents: 0, plans: [] },
planStatesAfter: buildPlanStates(inputs.plans, inputs.config, postedAt, inputs.user?.incomeType, false),
};
}
const inputs = await getInputs(tx, userId);
const adjustedInputs = { ...inputs, availableBefore: inputsBefore.availableBefore };
const result = computeAllocation(adjustedInputs, remainingBeforeAuto, postedAt, false);
await applyAllocations(tx as Prisma.TransactionClient, userId, incomeId, postedAt, result, false);
return {
...result,
fixedAllocations: [...manualFixedAllocations, ...result.fixedAllocations],
variableAllocations: [...manualVariableAllocations, ...result.variableAllocations],
};
});
}
// ===== Budget Allocation for Irregular Income =====
type BudgetAllocationResult = {
fixedAllocations: FixedAllocation[];
variableAllocations: VariableAllocation[];
totalBudgetCents: number;
fundedBudgetCents: number;
availableBudgetCents: number;
remainingBudgetCents: number;
crisis: {
active: boolean;
plans: CrisisPlan[];
};
planStatesAfter: PlanState[];
};
async function getBudgetSession(
tx: PrismaClient | Prisma.TransactionClient,
userId: string,
periodStart: Date
): Promise<any> {
const periodEnd = new Date(periodStart);
periodEnd.setMonth(periodEnd.getMonth() + 1); // Assume monthly periods for now
return await tx.budgetSession.findUnique({
where: {
userId_periodStart: {
userId,
periodStart,
},
},
});
}
async function createOrUpdateBudgetSession(
tx: PrismaClient | Prisma.TransactionClient,
userId: string,
periodStart: Date,
totalBudgetCents: number
): Promise<any> {
const periodEnd = new Date(periodStart);
periodEnd.setMonth(periodEnd.getMonth() + 1);
return await tx.budgetSession.upsert({
where: {
userId_periodStart: {
userId,
periodStart,
},
},
create: {
userId,
periodStart,
periodEnd,
totalBudgetCents: BigInt(totalBudgetCents),
allocatedCents: 0n,
fundedCents: 0n,
availableCents: 0n,
},
update: {
totalBudgetCents: BigInt(totalBudgetCents),
},
});
}
function computeBudgetAllocation(
inputs: Awaited<ReturnType<typeof getInputs>>,
newIncomeCents: number,
fixedExpensePercentage: number,
now: Date
): BudgetAllocationResult {
const newIncome = Math.max(0, Math.floor(newIncomeCents || 0));
const availableBudget = inputs.availableBefore;
const totalPool = availableBudget + newIncome;
const eligiblePlans = inputs.plans.filter(
(plan) => !plan.fundingMode || String(plan.fundingMode).toLowerCase() !== "manual"
);
const planStates = buildBudgetPlanStates(eligiblePlans, now, inputs.config.timezone);
// Calculate total remaining needed across all fixed plans
const totalRemainingNeeded = planStates.reduce((sum, p) => sum + p.remainingCents, 0);
// Fixed expenses get a percentage of NEW income, but capped at what's actually needed
const fixedPercentage = Math.max(0, Math.min(100, fixedExpensePercentage)) / 100;
const idealFixedAllocation = Math.floor(newIncome * fixedPercentage);
const fixedAllocationPool = Math.min(idealFixedAllocation, totalRemainingNeeded);
// Variables get the remaining pool (available budget + remaining new income)
const variableAllocationPool = totalPool - fixedAllocationPool;
// Distribute fixed allocation pool among fixed plans based on priority and crisis
const { allocations: fixedAllocations, updatedPlans, crisis, unusedMoney } =
distributeBudgetToFixed(planStates, fixedAllocationPool);
// Only reallocate unused fixed money to variables if there are NO fixed plans at all
const shouldReallocateUnused = planStates.length === 0;
const actualVariablePool = variableAllocationPool + (shouldReallocateUnused ? unusedMoney : 0);
const { allocations: variableAllocations, remainingIncome } =
allocateVariables(inputs.cats, actualVariablePool);
const totalFixedAllocated = fixedAllocations.reduce((sum: number, a: FixedAllocation) => sum + a.amountCents, 0);
const totalVariableAllocated = variableAllocations.reduce((sum: number, a: VariableAllocation) => sum + a.amountCents, 0);
return {
fixedAllocations,
variableAllocations,
totalBudgetCents: newIncome,
fundedBudgetCents: totalFixedAllocated,
availableBudgetCents: totalVariableAllocated,
remainingBudgetCents: remainingIncome + (shouldReallocateUnused ? 0 : unusedMoney),
crisis: {
active: crisis.some((p: CrisisPlan) => p.remainingCents > 0 && p.daysUntilDue <= 14),
plans: crisis,
},
planStatesAfter: updatedPlans,
};
}
function distributeBudgetToFixed(
planStates: PlanState[],
fixedAllocationPool: number
): {
allocations: FixedAllocation[];
updatedPlans: PlanState[];
crisis: CrisisPlan[];
unusedMoney: number;
} {
const allocations: FixedAllocation[] = [];
const updatedPlans = planStates.map((p) => ({ ...p }));
let remainingPool = fixedAllocationPool;
// Crisis plans get first priority (due within 14 days)
const crisisPlans = updatedPlans
.filter((p) => p.isCrisis && p.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
// Fund crisis plans first, up to their full need
for (const plan of crisisPlans) {
const allocation = Math.min(remainingPool, plan.remainingCents);
if (allocation > 0) {
allocations.push({ fixedPlanId: plan.id, amountCents: allocation, source: "income" });
remainingPool -= allocation;
plan.allocatedThisRun = allocation;
plan.remainingCents -= allocation;
plan.fundedCents += allocation;
}
}
// Remaining pool goes to non-crisis plans proportionally
const regularPlans = updatedPlans.filter((p) => !p.isCrisis && p.remainingCents > 0);
const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0);
if (remainingPool > 0 && totalRegularNeeded > 0) {
for (const plan of regularPlans) {
const proportion = plan.remainingCents / totalRegularNeeded;
const allocation = Math.floor(remainingPool * proportion);
if (allocation > 0) {
allocations.push({ fixedPlanId: plan.id, amountCents: allocation, source: "income" });
plan.allocatedThisRun = allocation;
plan.remainingCents -= allocation;
plan.fundedCents += allocation;
}
}
}
const crisisReport: CrisisPlan[] = crisisPlans.map((p) => ({
id: p.id,
name: p.name,
remainingCents: p.remainingCents,
daysUntilDue: p.daysUntilDue,
priority: p.priority,
allocatedCents: p.allocatedThisRun,
}));
return {
allocations,
updatedPlans,
crisis: crisisReport,
unusedMoney: remainingPool,
};
}
function buildBudgetPlanStates(
plans: Awaited<ReturnType<typeof getInputs>>["plans"],
now: Date,
timezone: string
): PlanState[] {
// For irregular income:
// - Longer crisis window (14 days instead of 7)
// - No frequency-based calculations
// - Desired funding is simply the remaining amount needed
return plans.map((p) => {
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0n);
const total = Number(p.totalCents ?? 0n);
const remainingCents = Math.max(0, total - funded);
const userNow = getUserMidnight(timezone, now);
const userDueDate = getUserMidnightFromDateOnly(timezone, p.dueOn);
const daysUntilDue = Math.max(0, Math.ceil((userDueDate.getTime() - userNow.getTime()) / DAY_MS));
const hasPaymentSchedule = p.paymentSchedule !== null && p.paymentSchedule !== undefined;
const autoFundEnabled =
!p.fundingMode || String(p.fundingMode).toLowerCase() !== "manual";
const needsFundingThisPeriod = p.needsFundingThisPeriod ?? true;
// For irregular income, crisis mode triggers earlier (14 days)
const isCrisis = remainingCents > 0 && daysUntilDue <= 14;
// Desired funding is the full remaining amount needed
const desiredThisIncome = remainingCents;
return {
id: p.id,
name: p.name,
totalCents: total,
fundedCents: funded,
dueOn: p.dueOn,
priority: p.priority,
remainingCents,
daysUntilDue,
desiredThisIncome,
isCrisis,
allocatedThisRun: 0,
needsFundingThisPeriod,
hasPaymentSchedule,
autoFundEnabled,
isOverdue: p.isOverdue ?? false,
overdueAmount: Number(p.overdueAmount ?? 0n),
overdueSince: p.overdueSince ?? null,
};
});
}
export async function allocateBudget(
db: PrismaClient,
userId: string,
newIncomeCents: number,
fixedExpensePercentage: number = 30,
postedAtISO?: string
): Promise<BudgetAllocationResult> {
const amt = Math.max(0, Math.floor(newIncomeCents || 0));
const postedAt = postedAtISO ? new Date(postedAtISO) : new Date();
return await db.$transaction(async (tx) => {
const inputs = await getInputs(tx, userId);
const result = computeBudgetAllocation(inputs, amt, fixedExpensePercentage, postedAt);
// Note: For irregular income, this is typically a preview/planning operation
// Actual funding would happen when income is received via allocateIncome
return result;
});
}
function mapBudgetResultToAllocation(result: BudgetAllocationResult): AllocationComputation {
return {
fixedAllocations: result.fixedAllocations,
variableAllocations: result.variableAllocations,
remainingUnallocatedCents: result.remainingBudgetCents,
availableBudgetAfterCents: result.availableBudgetCents,
crisis: {
active: result.crisis.active,
pulledFromAvailableCents: 0,
plans: result.crisis.plans,
},
planStatesAfter: result.planStatesAfter,
};
}
async function applyIrregularIncomeInTx(
tx: Prisma.TransactionClient,
userId: string,
amountCents: number,
fixedExpensePercentage: number,
postedAt: Date,
incomeId: string,
note?: string | null
): Promise<BudgetAllocationResult> {
const amt = Math.max(0, Math.floor(amountCents | 0));
const inputs = await getInputs(tx, userId);
await tx.incomeEvent.upsert({
where: { id: incomeId },
update: {},
create: {
id: incomeId,
userId,
postedAt,
amountCents: BigInt(amt),
note: note ?? null,
isScheduledIncome: false,
},
});
const result = computeBudgetAllocation(inputs, amt, fixedExpensePercentage, postedAt);
for (const alloc of result.fixedAllocations) {
const allocAmount = Math.max(0, Math.floor(alloc.amountCents || 0));
if (allocAmount <= 0) continue;
await tx.fixedPlan.update({
where: { id: alloc.fixedPlanId },
data: {
fundedCents: { increment: BigInt(allocAmount) },
currentFundedCents: { increment: BigInt(allocAmount) },
lastFundingDate: postedAt,
},
});
await tx.allocation.create({
data: {
userId,
kind: "fixed",
toId: alloc.fixedPlanId,
amountCents: BigInt(allocAmount),
incomeId,
},
});
}
for (const alloc of result.variableAllocations) {
const allocAmount = Math.max(0, Math.floor(alloc.amountCents || 0));
if (allocAmount <= 0) continue;
await tx.variableCategory.update({
where: { id: alloc.variableCategoryId },
data: { balanceCents: { increment: BigInt(allocAmount) } },
});
await tx.allocation.create({
data: {
userId,
kind: "variable",
toId: alloc.variableCategoryId,
amountCents: BigInt(allocAmount),
incomeId,
},
});
}
return result;
}
// Function for actually applying irregular income when it arrives
export async function applyIrregularIncome(
db: PrismaClient,
userId: string,
amountCents: number,
fixedExpensePercentage: number,
postedAtISO: string,
incomeId: string,
note?: string | null
): Promise<BudgetAllocationResult> {
const postedAt = new Date(postedAtISO);
return await db.$transaction(async (tx) =>
applyIrregularIncomeInTx(tx, userId, amountCents, fixedExpensePercentage, postedAt, incomeId, note)
);
}