summaryrefslogtreecommitdiff
path: root/tests/FundLab.Domain.Tests/DomainTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 12:03:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 12:03:39 +0800
commitf4ab0b08d7648914f6fd049fa1887c0422f69318 (patch)
treec2dc484ed97bd0dc573986318cce921340fbd783 /tests/FundLab.Domain.Tests/DomainTests.fs
parent205e2a592bf781f08fe23f3577cdfe74ff9a076a (diff)
downloadfund-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 'tests/FundLab.Domain.Tests/DomainTests.fs')
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs61
1 files changed, 61 insertions, 0 deletions
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index 46516d5..13be13f 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -560,3 +560,64 @@ module CapitalPolicyTests =
let overflow = CapitalPolicy.settle CapitalPolicy.cashMaximum 0.01m
Assert.Equal(Error "available cash exceeds database precision", overflow)
+
+module SipPolicyTests =
+
+ open System
+ open Xunit
+ open FundLab.Domain
+
+ let d (year: int) (month: int) (day: int) = DateOnly(year, month, day)
+
+ [<Fact>]
+ let ``sip amount validation rejects non positive or imprecise values`` () =
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.validateAmount 0m)
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.validateAmount -200.00m)
+ Assert.Equal(Error "sip amount exceeds cash precision", SipPolicy.validateAmount 200.005m)
+ Assert.Equal(Ok(), SipPolicy.validateAmount 200.00m)
+
+ [<Fact>]
+ let ``frequency parsing roundtrips through the canonical text`` () =
+ Assert.Equal(Some Weekly, SipPolicy.parseFrequency "weekly")
+ Assert.Equal(Some BiWeekly, SipPolicy.parseFrequency "biweekly")
+ Assert.Equal(Some Monthly, SipPolicy.parseFrequency "monthly")
+ Assert.Equal(None, SipPolicy.parseFrequency "daily")
+ Assert.Equal(None, SipPolicy.parseFrequency null)
+
+ for frequency in [ Weekly; BiWeekly; Monthly ] do
+ Assert.Equal(Some frequency, SipPolicy.parseFrequency (SipPolicy.frequencyText frequency))
+
+ [<Fact>]
+ let ``weekly and biweekly next trade dates step from the anchor`` () =
+ let anchor = d 2026 9 21
+
+ Assert.Equal(d 2026 9 21, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 21))
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 22))
+ Assert.Equal(d 2026 10 5, SipPolicy.nextTradeDate BiWeekly anchor (d 2026 9 28))
+
+ [<Fact>]
+ let ``next trade dates roll off weekends`` () =
+ let anchor = d 2026 9 21
+
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 26))
+ Assert.Equal(d 2026 9 28, SipPolicy.nextTradeDate Weekly anchor (d 2026 9 27))
+
+ [<Fact>]
+ let ``monthly next trade date clamps to month end and rolls off weekends`` () =
+ let anchor = d 2026 1 31
+
+ // 2026-01-31 is a Saturday: first run rolls to Monday 02-02
+ Assert.Equal(d 2026 2 2, SipPolicy.nextTradeDate Monthly anchor anchor)
+ // February clamps day 31 to 28 (Saturday) and rolls to Monday 03-02
+ Assert.Equal(d 2026 3 2, SipPolicy.nextTradeDate Monthly anchor (d 2026 2 1))
+ Assert.Equal(d 2026 3 2, SipPolicy.nextTradeDate Monthly anchor (d 2026 2 28))
+ Assert.Equal(d 2026 2 5, SipPolicy.nextTradeDate Monthly (d 2026 1 5) (d 2026 1 20))
+
+ [<Fact>]
+ let ``due flag and net amount define the executor contract without side effects`` () =
+ Assert.True(SipPolicy.isDue (d 2026 9 21) (d 2026 9 21))
+ Assert.True(SipPolicy.isDue (d 2026 9 21) (d 2026 9 28))
+ Assert.False(SipPolicy.isDue (d 2026 9 28) (d 2026 9 21))
+
+ Assert.Equal(Ok 200.00m, SipPolicy.executionNetAmount 200.00m)
+ Assert.Equal(Error "sip amount must be positive", SipPolicy.executionNetAmount 0m)