// services/dailyBalanceCache.ts // Cached daily balance computation with incremental updates and time-based expiration import { kvStore } from "../modules/KvCacheStore.ts"; import { Income, Obligation } from "./financhalServices.ts"; export interface DailyBalanceEntry { date: string; // YYYY-MM-DD balance: number; dayIncome: number; dayObligation: number; } export interface MonthlyBalanceCache { userId: string; year: number; month: number; // 1-12 startDate: string; // YYYY-MM-DD - first day with balance data startBalance: number; // Balance at startDate (before day's transactions) entries: DailyBalanceEntry[]; computedAt: number; // Unix timestamp } export interface ComputeBalanceOptions { ttl?: number; // Cache TTL in milliseconds (default: 1 hour) forceRecompute?: boolean; } const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour const CACHE_PREFIX = ["daily_balances"]; // Get cache key for a user's month function getCacheKey(userId: string, year: number, month: number): Deno.KvKey { return [...CACHE_PREFIX, userId, String(year), String(month)]; } // Get days in month function getDaysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate(); } // Format date to YYYY-MM-DD function formatDateStr(year: number, month: number, day: number): string { const monthStr = month < 9 ? `0${month + 1}` : `${month + 1}`; const dayStr = day < 10 ? `0${day}` : `${day}`; return `${year}-${monthStr}-${dayStr}`; } // Advance a date by frequency function advanceDate( date: Date, frequency: string, ): Date { switch (frequency.toLowerCase()) { case "daily": return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); case "weekly": return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7); case "biweekly": return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 14); case "monthly": { // Advance by one month, preserving the day number return new Date(date.getFullYear(), date.getMonth() + 1, date.getDate()); } case "quarterly": return new Date(date.getFullYear(), date.getMonth() + 3, date.getDate()); case "yearly": case "annually": { // Advance by year, preserving month/day const newYear = date.getFullYear() + 1; const newMonth = date.getMonth(); const newDay = date.getDate(); return new Date(newYear, newMonth, newDay); } default: return date; // Unknown frequency - don't advance } } // Generate recurring income occurrences for a specific month (for display) export function generateRecurringIncomesForMonth( incomes: Income[], targetYear: number, targetMonth: number, ): Income[] { const result: Income[] = []; for (const income of incomes) { if (!income.frequency || !income.received_date) continue; let currentDate = new Date(income.received_date); const targetMonthIdx = targetMonth - 1; // Advance until we reach or pass the target month/year while ( currentDate.getFullYear() < targetYear || currentDate.getMonth() < targetMonthIdx ) { currentDate = advanceDate(currentDate, income.frequency); // Safety check for one-time or unknown frequencies if ( income.frequency.toLowerCase() === "one-time" || currentDate.getFullYear() > targetYear + 2 ) { break; } } // Add all occurrences in target month while ( currentDate.getFullYear() === targetYear && currentDate.getMonth() === targetMonthIdx ) { // Create timestamp using local date components to avoid timezone issues const localTimestamp = new Date( currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), ).getTime(); result.push({ ...income, received_date: localTimestamp, }); currentDate = advanceDate(currentDate, income.frequency); } } return result; } // Generate recurring obligation occurrences for a specific month (for display) export function generateRecurringObligationsForMonth( obligations: Obligation[], targetYear: number, targetMonth: number, ): Obligation[] { const result: Obligation[] = []; for (const obligation of obligations) { if (!obligation.frequency || !obligation.due_date) continue; let currentDate = new Date(obligation.due_date); const targetMonthIdx = targetMonth - 1; // Advance until we reach or pass the target month/year while ( currentDate.getFullYear() < targetYear || currentDate.getMonth() < targetMonthIdx ) { currentDate = advanceDate( currentDate, obligation.frequency, ); // Safety check for one-time or unknown frequencies if ( obligation.frequency.toLowerCase() === "one-time" || currentDate.getFullYear() > targetYear + 2 ) { break; } } // Add all occurrences in target month while ( currentDate.getFullYear() === targetYear && currentDate.getMonth() === targetMonthIdx ) { // Create timestamp using local date components to avoid timezone issues const localTimestamp = new Date( currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate(), ).getTime(); result.push({ ...obligation, due_date: localTimestamp, }); currentDate = advanceDate( currentDate, obligation.frequency, ); } } return result; } // Parse YYYY-MM-DD string to date components function parseDateStr( dateStr: string, ): { year: number; month: number; day: number } { const [year, month, day] = dateStr.split("-").map(Number); return { year, month: month - 1, day }; // month is 0-indexed in Date } // Compute daily balances for a specific month export async function computeMonthlyBalances( userId: string, year: number, month: number, incomes: Income[], obligations: Obligation[], baseBalance: { date: string; balance: number } | null, options: ComputeBalanceOptions = {}, ): Promise { const { ttl = DEFAULT_CACHE_TTL, forceRecompute = false } = options; // Check cache first (unless force recompute) if (!forceRecompute) { const cached = await getCachedMonthlyBalances(userId, year, month); if (cached && (Date.now() - cached.computedAt) < ttl) { return cached; } } // Compute from scratch or incremental const result: MonthlyBalanceCache = { userId, year, month, startDate: baseBalance?.date || formatDateStr(year, month - 1, 1), startBalance: baseBalance?.balance || 0, entries: [], computedAt: Date.now(), }; const daysInMonth = getDaysInMonth(year, month - 1); let runningBalance = baseBalance?.balance || 0; // If we have a base balance, start from that date const startDate = baseBalance ? parseDateStr(baseBalance.date) : null; // Compute incomes by day for this month const incomesByDay: Record = {}; const obligationsByDay: Record = {}; // Initialize day arrays for (let d = 1; d <= daysInMonth; d++) { incomesByDay[d] = []; obligationsByDay[d] = []; } // Generate recurring incomes for this month for (const income of incomes) { if (!income.frequency || !income.received_date) continue; let currentDate = new Date(income.received_date); const targetMonth = month - 1; // 0-indexed // Advance until we reach or pass the target month/year while ( currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth ) { currentDate = advanceDate( currentDate, income.frequency, ); // Safety check for one-time or unknown frequencies if ( income.frequency.toLowerCase() === "one-time" || currentDate.getFullYear() > year + 2 ) { break; } } // Add all occurrences in target month while ( currentDate.getFullYear() === year && currentDate.getMonth() === targetMonth ) { const day = currentDate.getDate(); if (day <= daysInMonth) { incomesByDay[day].push(income.amount || 0); } currentDate = advanceDate( currentDate, income.frequency, ); } } // Generate recurring obligations for this month for (const obligation of obligations) { if (!obligation.frequency || !obligation.due_date) continue; let currentDate = new Date(obligation.due_date); const targetMonth = month - 1; // 0-indexed // Advance until we reach or pass the target month/year while ( currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth ) { currentDate = advanceDate( currentDate, obligation.frequency, ); // Safety check for one-time or unknown frequencies if ( obligation.frequency.toLowerCase() === "one-time" || currentDate.getFullYear() > year + 2 ) { break; } } // Add all occurrences in target month while ( currentDate.getFullYear() === year && currentDate.getMonth() === targetMonth ) { const day = currentDate.getDate(); if (day <= daysInMonth) { obligationsByDay[day].push(obligation.amount || 0); } currentDate = advanceDate( currentDate, obligation.frequency, ); } } // Calculate running balance for each day const startDay = startDate && startDate.year === year && startDate.month === month - 1 ? startDate.day : 1; for (let day = startDay; day <= daysInMonth; day++) { const dayIncome = incomesByDay[day].reduce((sum, amt) => sum + amt, 0); const dayObligation = obligationsByDay[day].reduce( (sum, amt) => sum + amt, 0, ); runningBalance += dayIncome - dayObligation; result.entries.push({ date: formatDateStr(year, month - 1, day), balance: runningBalance, dayIncome, dayObligation, }); } // Cache the result await cacheMonthlyBalances(result); return result; } // Cache monthly balances export async function cacheMonthlyBalances( cache: MonthlyBalanceCache, ): Promise { const key = getCacheKey(cache.userId, cache.year, cache.month); await kvStore.set(key, cache); } // Retrieve cached monthly balances export async function getCachedMonthlyBalances( userId: string, year: number, month: number, ): Promise { const key = getCacheKey(userId, year, month); return await kvStore.get(key); } // Invalidate cache for a user (call when incomes/obligations change) export async function invalidateBalanceCache(userId: string): Promise { // List all cache entries for this user const entries: Array<{ key: Deno.KvKey; value: unknown }> = []; for await ( const entry of kvStore.list({ prefix: [...CACHE_PREFIX, userId], }) ) { entries.push(entry); } // Delete all entries for (const entry of entries) { await kvStore.delete(entry.key); } } // Invalidate cache for a specific month (more targeted) export async function invalidateMonthCache( userId: string, year: number, month: number, ): Promise { const key = getCacheKey(userId, year, month); await kvStore.delete(key); } // Clear all expired cache entries (cleanup utility) export async function clearExpiredCache( maxAge: number = DEFAULT_CACHE_TTL, ): Promise { const now = Date.now(); let deletedCount = 0; for await ( const entry of kvStore.list({ prefix: CACHE_PREFIX, }) ) { const cache = entry.value; if (now - cache.computedAt > maxAge) { await kvStore.delete(entry.key); deletedCount++; } } return deletedCount; }