1685 lines
55 KiB
TypeScript
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)
|
|
);
|
|
}
|