namespace FundLab.Domain open System type SipFrequency = | Weekly | BiWeekly | Monthly module SipPolicy = let cashMaximum = 999999999999999999.99m let validateAmount (amount: decimal) : Result = if amount <= 0m then Error "sip amount must be positive" elif Decimal.Round(amount, 2) <> amount then Error "sip amount exceeds cash precision" elif amount > cashMaximum then Error "sip amount exceeds database precision" else Ok () let parseFrequency (text: string) : SipFrequency option = match text with | null -> None | "weekly" -> Some Weekly | "biweekly" -> Some BiWeekly | "monthly" -> Some Monthly | _ -> None let frequencyText (frequency: SipFrequency) : string = match frequency with | Weekly -> "weekly" | BiWeekly -> "biweekly" | 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 intervalDays (frequency: SipFrequency) : int = match frequency with | Weekly -> 7 | BiWeekly -> 14 | Monthly -> 0 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) /// Next due trade date for a plan: the first run date of the plan's cadence that is /// on or after `fromDate`, rolled forward off weekends (no-lookahead weekday rule). let nextTradeDate (frequency: SipFrequency) (anchorDate: DateOnly) (fromDate: DateOnly) : DateOnly = match frequency with | Weekly | BiWeekly -> let interval = intervalDays frequency let anchorTicks = anchorDate.DayNumber let fromTicks = fromDate.DayNumber let steps = max 0 ((fromTicks - anchorTicks + interval - 1) / interval) rollToWeekday (anchorDate.AddDays(steps * interval)) | Monthly -> let monthsBetween = (fromDate.Year - anchorDate.Year) * 12 + (fromDate.Month - anchorDate.Month) let candidateMonths = if fromDate.Day <= anchorDate.Day || monthsBetween < 0 then monthsBetween else monthsBetween + 1 let months = max 0 candidateMonths rollToWeekday (addMonthsPreservingDay anchorDate months) /// Execution is out of scope for this slice. These pure helpers define the /// due/settlement contract that the (future) executor will implement: when a /// plan is due, the net amount debited equals the plan amount exactly, and no /// units, NAV or realized income are touched until that executor lands (3d-6+). let isDue (nextTradeDate: DateOnly) (today: DateOnly) : bool = today >= nextTradeDate let executionNetAmount (plannedAmount: decimal) : Result = match validateAmount plannedAmount with | Error message -> Error message | Ok() -> Ok plannedAmount