diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:03:39 +0800 |
| commit | f4ab0b08d7648914f6fd049fa1887c0422f69318 (patch) | |
| tree | c2dc484ed97bd0dc573986318cce921340fbd783 /src/FundLab.Domain | |
| parent | 205e2a592bf781f08fe23f3577cdfe74ff9a076a (diff) | |
| download | fund-lab-f4ab0b08d7648914f6fd049fa1887c0422f69318.tar.gz | |
Add SIP plan first slice (3d-5)
Introduce scheduled-investment plans as a schedule-only slice with no
cash, position or NAV side effects. Domain gains SipPolicy: amount
validation (positive, two-decimal, overflow-checked), frequency
parsing/round-trip (weekly/biweekly/monthly), nextTradeDate generating
the next due trade date off the plan anchor with month-end clamping
and weekend roll-forward, plus the isDue/executionNetAmount contract
the future executor will implement (execution itself stays
unimplemented and is marked as such; nothing debits cash in this
slice).
Persistence stores plans in sip_plans with anchor/next trade dates and
sip_plan_idempotencies under the shared replay/conflict contract; the
API exposes POST/GET /funds/{id}/sip/plans behind the bearer token.
The frontend adds a 07/SIP panel (code, amount, frequency select,
plan list with next debit date) and Web tests cover decoding,
validation, in-flight guards and stale completions. Choice note:
first debit is scheduled strictly after the anchor date (creating a
plan today never debits today).
Diffstat (limited to 'src/FundLab.Domain')
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/Sip.fs | 94 |
2 files changed, 95 insertions, 0 deletions
diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 0daf3ea..5027edd 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -10,6 +10,7 @@ <Compile Include="Ledger.fs" /> <Compile Include="Redemption.fs" /> <Compile Include="Capital.fs" /> + <Compile Include="Sip.fs" /> <Compile Include="Performance.fs" /> </ItemGroup> </Project> diff --git a/src/FundLab.Domain/Sip.fs b/src/FundLab.Domain/Sip.fs new file mode 100644 index 0000000..2db7fbd --- /dev/null +++ b/src/FundLab.Domain/Sip.fs @@ -0,0 +1,94 @@ +namespace FundLab.Domain + +open System + +type SipFrequency = + | Weekly + | BiWeekly + | Monthly + +module SipPolicy = + let cashMaximum = 999999999999999999.99m + + let validateAmount (amount: decimal) : Result<unit, string> = + 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<decimal, string> = + match validateAmount plannedAmount with + | Error message -> Error message + | Ok() -> Ok plannedAmount |
