From f4ab0b08d7648914f6fd049fa1887c0422f69318 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 12:03:39 +0800 Subject: 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). --- src/FundLab.Domain/FundLab.Domain.fsproj | 1 + src/FundLab.Domain/Sip.fs | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/FundLab.Domain/Sip.fs (limited to 'src/FundLab.Domain') 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 @@ + 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 = + 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 -- cgit v1.2.3