integratedhub/services/dailyBalanceCache.ts
2026-06-04 13:05:50 +00:00

424 lines
12 KiB
TypeScript

// 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<MonthlyBalanceCache> {
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<number, number[]> = {};
const obligationsByDay: Record<number, number[]> = {};
// 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<void> {
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<MonthlyBalanceCache | null> {
const key = getCacheKey(userId, year, month);
return await kvStore.get<MonthlyBalanceCache>(key);
}
// Invalidate cache for a user (call when incomes/obligations change)
export async function invalidateBalanceCache(userId: string): Promise<void> {
// List all cache entries for this user
const entries: Array<{ key: Deno.KvKey; value: unknown }> = [];
for await (
const entry of kvStore.list<MonthlyBalanceCache>({
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<void> {
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<number> {
const now = Date.now();
let deletedCount = 0;
for await (
const entry of kvStore.list<MonthlyBalanceCache>({
prefix: CACHE_PREFIX,
})
) {
const cache = entry.value;
if (now - cache.computedAt > maxAge) {
await kvStore.delete(entry.key);
deletedCount++;
}
}
return deletedCount;
}