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 = { 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 { 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>["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>["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>, 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> { // Fixed plans const planUpdates = new Map(); const fixedAllocationRows = new Map(); result.fixedAllocations.forEach((a) => { planUpdates.set(a.fixedPlanId, (planUpdates.get(a.fixedPlanId) ?? 0) + a.amountCents); const rowKey = `${a.fixedPlanId}:${a.source}`; const existing = fixedAllocationRows.get(rowKey); if (existing) { existing.amountCents += a.amountCents; return; } fixedAllocationRows.set(rowKey, { planId: a.fixedPlanId, source: a.source, amountCents: a.amountCents, }); }); const fullyFundedPlans: Array<{id: string, name: string, dueOn: Date}> = []; 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 { 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(); 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 { 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 { 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 { 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>, 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>["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 { 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 { 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 { const postedAt = new Date(postedAtISO); return await db.$transaction(async (tx) => applyIrregularIncomeInTx(tx, userId, amountCents, fixedExpensePercentage, postedAt, incomeId, note) ); }