namespace FundLab.Domain.Tests module InvestmentPlanTests = open System open Xunit open FundLab.Domain let private anchor = DateOnly(2026, 9, 21) [] let ``frequency parsing and text roundtrip`` () = Assert.Equal(Some InvestmentFrequency.Daily, InvestmentPlanPolicy.parseFrequency "daily") Assert.Equal(Some InvestmentFrequency.Weekly, InvestmentPlanPolicy.parseFrequency "weekly") Assert.Equal(Some InvestmentFrequency.Monthly, InvestmentPlanPolicy.parseFrequency "monthly") Assert.Equal(None, InvestmentPlanPolicy.parseFrequency "biweekly") Assert.Equal(None, InvestmentPlanPolicy.parseFrequency null) Assert.Equal("daily", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Daily) Assert.Equal("weekly", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Weekly) Assert.Equal("monthly", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Monthly) [] let ``amount validation requires positive two-decimal cash`` () = Assert.Equal(Ok(), InvestmentPlanPolicy.validateAmount 200.00m) Assert.Equal(Error "investment plan amount must be positive", InvestmentPlanPolicy.validateAmount 0m) Assert.Equal(Error "investment plan amount must be positive", InvestmentPlanPolicy.validateAmount -1.00m) Assert.Equal(Error "investment plan amount exceeds cash precision", InvestmentPlanPolicy.validateAmount 1.005m) [] let ``daily schedule runs every weekday and skips weekends`` () = Assert.Equal(DateOnly(2026, 9, 21), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor anchor) Assert.Equal(DateOnly(2026, 9, 22), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor (DateOnly(2026, 9, 22))) // Friday -> next scheduled run is Monday Assert.Equal(DateOnly(2026, 9, 28), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor (DateOnly(2026, 9, 26))) let due = InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily (DateOnly(2026, 9, 25)) (DateOnly(2026, 9, 25)) (DateOnly(2026, 9, 28)) Assert.Equal([ DateOnly(2026, 9, 25); DateOnly(2026, 9, 28) ], due) [] let ``weekly schedule keeps the seven day cadence`` () = Assert.Equal(DateOnly(2026, 9, 21), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Weekly anchor anchor) Assert.Equal(DateOnly(2026, 9, 28), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Weekly anchor (DateOnly(2026, 9, 22))) let due = InvestmentPlanPolicy.dueDates InvestmentFrequency.Weekly anchor anchor (DateOnly(2026, 10, 5)) Assert.Equal( [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 28); DateOnly(2026, 10, 5) ], due ) [] let ``monthly schedule clamps to month end and rolls off weekends`` () = // 2026-02-15 is a Sunday, so the February run rolls to Monday 2026-02-16 Assert.Equal(DateOnly(2026, 2, 16), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Monthly (DateOnly(2026, 1, 15)) (DateOnly(2026, 2, 1))) // 2026-02 has 28 days, so a 31st anchor clamps to the 28th then rolls to Monday 2026-03-02 Assert.Equal(DateOnly(2026, 3, 2), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Monthly (DateOnly(2026, 1, 31)) (DateOnly(2026, 2, 1))) let due = InvestmentPlanPolicy.dueDates InvestmentFrequency.Monthly anchor anchor (DateOnly(2026, 11, 30)) // 2026-11-21 is a Saturday, so the November run rolls to Monday 2026-11-23 Assert.Equal( [ DateOnly(2026, 9, 21); DateOnly(2026, 10, 21); DateOnly(2026, 11, 23) ], due ) [] let ``due dates are truncated at the processing window`` () = Assert.Equal([], InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 20))) let truncated = InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 22)) Assert.Equal([ DateOnly(2026, 9, 21); DateOnly(2026, 9, 22) ], truncated) // a repeat drive over the same window yields the same dates (idempotent schedule) Assert.Equal(truncated, InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 22)))