1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
namespace FundLab.Domain.Tests
module InvestmentPlanTests =
open System
open Xunit
open FundLab.Domain
let private anchor = DateOnly(2026, 9, 21)
[<Fact>]
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)
[<Fact>]
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)
[<Fact>]
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 list>([ DateOnly(2026, 9, 25); DateOnly(2026, 9, 28) ], due)
[<Fact>]
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 list>(
[ DateOnly(2026, 9, 21); DateOnly(2026, 9, 28); DateOnly(2026, 10, 5) ],
due
)
[<Fact>]
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 list>(
[ DateOnly(2026, 9, 21); DateOnly(2026, 10, 21); DateOnly(2026, 11, 23) ],
due
)
[<Fact>]
let ``due dates are truncated at the processing window`` () =
Assert.Equal<DateOnly list>([], 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 list>([ DateOnly(2026, 9, 21); DateOnly(2026, 9, 22) ], truncated)
// a repeat drive over the same window yields the same dates (idempotent schedule)
Assert.Equal<DateOnly list>(truncated, InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 22)))
|