424 lines
12 KiB
TypeScript
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;
|
|
}
|