import { RRule, rrulestr } from "rrule"; import type { RecurringRule, Transaction } from "@/db/schema"; export type ProjectedTransaction = { id: string; bankAccountId: string; categoryId: string | null; recurringRuleId: string; date: string; amountMinor: number; description: string; status: "projected"; source: "recurring"; }; function toISODate(d: Date): string { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function parseISODate(s: string): Date { const [y, m, d] = s.split("-").map(Number); return new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1)); } export function rruleFromRule(rule: Pick): RRule { const dtstart = parseISODate(rule.startDate); const until = rule.endDate ? parseISODate(rule.endDate) : undefined; const trimmed = rule.rrule.trim(); if (trimmed.startsWith("RRULE:") || trimmed.startsWith("DTSTART")) { const r = rrulestr(trimmed) as RRule; return new RRule({ ...r.options, dtstart, until: until ?? r.options.until }); } const opts = RRule.parseString(trimmed); return new RRule({ ...opts, dtstart, until: until ?? opts.until }); } export function generateOccurrences( rule: Pick, from: Date, to: Date, ): Date[] { const r = rruleFromRule(rule); return r.between(from, to, true); } export function projectTransactionsFromRule( rule: RecurringRule, from: Date, to: Date, postedKeyset: Set = new Set(), ): ProjectedTransaction[] { if (!rule.isActive) return []; const dates = generateOccurrences(rule, from, to); return dates .map((d) => { const dateStr = toISODate(d); const dedupKey = `${rule.id}|${dateStr}`; if (postedKeyset.has(dedupKey)) return null; return { id: `proj_${rule.id}_${dateStr}`, bankAccountId: rule.bankAccountId, categoryId: rule.categoryId, recurringRuleId: rule.id, date: dateStr, amountMinor: rule.amountMinor, description: rule.description, status: "projected" as const, source: "recurring" as const, } satisfies ProjectedTransaction; }) .filter((x): x is ProjectedTransaction => x !== null); } export type LedgerRow = { id: string; bankAccountId: string; categoryId: string | null; recurringRuleId: string | null; date: string; amountMinor: number; description: string; status: "posted" | "projected" | "cancelled"; source: string; runningBalanceMinor: number; }; export function buildLedger( posted: Transaction[], rules: RecurringRule[], from: Date, to: Date, openingBalanceByAccount: Record, ): LedgerRow[] { const postedKeyset = new Set( posted .filter((t) => t.recurringRuleId) .map((t) => `${t.recurringRuleId}|${t.date}`), ); const projected = rules.flatMap((r) => projectTransactionsFromRule(r, from, to, postedKeyset), ); const all = [ ...posted.map((p) => ({ id: p.id, bankAccountId: p.bankAccountId, categoryId: p.categoryId, recurringRuleId: p.recurringRuleId, date: p.date, amountMinor: p.amountMinor, description: p.description, status: p.status, source: p.source, })), ...projected.map((p) => ({ id: p.id, bankAccountId: p.bankAccountId, categoryId: p.categoryId, recurringRuleId: p.recurringRuleId, date: p.date, amountMinor: p.amountMinor, description: p.description, status: p.status as "projected", source: p.source as string, })), ].sort((a, b) => { if (a.date < b.date) return -1; if (a.date > b.date) return 1; if (a.status === "posted" && b.status !== "posted") return -1; if (a.status !== "posted" && b.status === "posted") return 1; return 0; }); const balances: Record = { ...openingBalanceByAccount }; return all.map((row) => { const prev = balances[row.bankAccountId] ?? 0; const next = row.status === "cancelled" ? prev : prev + row.amountMinor; balances[row.bankAccountId] = next; return { ...row, runningBalanceMinor: next }; }); } export const RRULE_PRESETS: Array<{ label: string; rrule: string }> = [ { label: "Daily", rrule: "FREQ=DAILY" }, { label: "Weekly (Mon)", rrule: "FREQ=WEEKLY;BYDAY=MO" }, { label: "Weekly (Fri)", rrule: "FREQ=WEEKLY;BYDAY=FR" }, { label: "Every 2 weeks", rrule: "FREQ=WEEKLY;INTERVAL=2" }, { label: "Monthly (same day)", rrule: "FREQ=MONTHLY" }, { label: "Monthly (1st)", rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }, { label: "Monthly (last day)", rrule: "FREQ=MONTHLY;BYMONTHDAY=-1" }, { label: "Yearly", rrule: "FREQ=YEARLY" }, ];