blob: ecc34255d48764819121ca7a56d9afab2dbca9fd (
plain)
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
namespace FundLab.Domain
open System
type InvestmentFrequency =
| Daily
| Weekly
| Monthly
module InvestmentPlanPolicy =
let cashMaximum = 999999999999999999.99m
let validateAmount (amount: decimal) : Result<unit, string> =
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 []
|