namespace FundLab.Domain open System type InvestmentFrequency = | Daily | Weekly | Monthly module InvestmentPlanPolicy = let cashMaximum = 999999999999999999.99m let validateAmount (amount: decimal) : Result = if amount <= 0m then Error "investment plan amount must be positive" elif Decimal.Round(amount, 2) <> amount then Error "investment plan amount exceeds cash precision" elif amount > cashMaximum then Error "investment plan amount exceeds database precision" else Ok() let parseFrequency (text: string) : InvestmentFrequency option = match text with | null -> None | "daily" -> Some InvestmentFrequency.Daily | "weekly" -> Some InvestmentFrequency.Weekly | "monthly" -> Some InvestmentFrequency.Monthly | _ -> None let frequencyText (frequency: InvestmentFrequency) : string = match frequency with | InvestmentFrequency.Daily -> "daily" | InvestmentFrequency.Weekly -> "weekly" | InvestmentFrequency.Monthly -> "monthly" let private isWeekend (date: DateOnly) = date.DayOfWeek = DayOfWeek.Saturday || date.DayOfWeek = DayOfWeek.Sunday let private rollToWeekday (date: DateOnly) : DateOnly = let mutable candidate = date while isWeekend candidate do candidate <- candidate.AddDays 1 candidate let private addMonthsPreservingDay (anchor: DateOnly) (months: int) : DateOnly = let targetMonthIndex = anchor.Year * 12 + (anchor.Month - 1) + months let year = targetMonthIndex / 12 let month = targetMonthIndex % 12 + 1 let daysInTargetMonth = DateTime.DaysInMonth(year, month) DateOnly(year, month, min anchor.Day daysInTargetMonth) /// First scheduled run date on or after `fromDate`, rolled forward off weekends. /// The anchor date is the plan's first scheduled run date. Calendar approximation: /// only weekends are skipped; public holidays are not modeled yet (no fabrication). let nextRunDate (frequency: InvestmentFrequency) (anchorDate: DateOnly) (fromDate: DateOnly) : DateOnly = let baseDate = if fromDate < anchorDate then anchorDate else fromDate match frequency with | InvestmentFrequency.Daily -> rollToWeekday baseDate | InvestmentFrequency.Weekly -> let steps = max 0 ((baseDate.DayNumber - anchorDate.DayNumber + 6) / 7) rollToWeekday (anchorDate.AddDays(steps * 7)) | InvestmentFrequency.Monthly -> let monthsBetween = (baseDate.Year - anchorDate.Year) * 12 + (baseDate.Month - anchorDate.Month) let candidateMonths = if baseDate.Day <= anchorDate.Day || monthsBetween < 0 then monthsBetween else monthsBetween + 1 rollToWeekday (addMonthsPreservingDay anchorDate (max 0 candidateMonths)) /// Due run dates from `dueFrom` through `endDate` inclusive, skipping weekends. /// Empty when nothing is due in the window. This is the "calendar truncation": /// a run never fires past `endDate`, and a repeat drive over the same window /// yields the same dates. let dueDates (frequency: InvestmentFrequency) (anchorDate: DateOnly) (dueFrom: DateOnly) (endDate: DateOnly) : DateOnly list = let rec loop (current: DateOnly) (acc: DateOnly list) = if current > endDate then List.rev acc else loop (nextRunDate frequency anchorDate (current.AddDays 1)) (current :: acc) if dueFrom > endDate then [] else let start = nextRunDate frequency anchorDate dueFrom if start > endDate then [] else loop start []