summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/FundLab.Api/App.fs104
-rw-r--r--src/FundLab.Api/Persistence.fs245
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/Sip.fs94
-rw-r--r--src/FundLab.Web/App.fs324
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs132
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs61
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs94
9 files changed, 1071 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 55a29f4..9f88468 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -4,6 +4,7 @@ open System
open System.Globalization
open System.IO
open System.Text.Json
+open FundLab.Domain
open Giraffe
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
@@ -131,6 +132,20 @@ type FundPositionsResponse =
positions: FundPositionResponse list
}
+type SipPlanResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextTradeDate: string
+ isSynthetic: bool
+ createdAt: string
+ }
+
type CapitalDepositResponse =
{
id: Guid
@@ -281,6 +296,20 @@ module App =
createdAt = timestampText deposit.CreatedAt
}
+ let private sipPlanResponse (plan: SipPlanRecord) : SipPlanResponse =
+ {
+ id = plan.Id
+ fundId = plan.FundId
+ instrumentCode = plan.InstrumentCode
+ amount = cashText plan.Amount
+ frequency = SipPolicy.frequencyText plan.Frequency
+ status = plan.Status
+ anchorDate = dateText plan.AnchorDate
+ nextTradeDate = dateText plan.NextTradeDate
+ isSynthetic = plan.IsSynthetic
+ createdAt = timestampText plan.CreatedAt
+ }
+
let private errorResponse status error message : HttpHandler =
setStatusCode status
>=> json ({
@@ -411,8 +440,34 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
+ let private parseSipPlanCommand (body: string) =
+ try
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "instrumentCode", tryStringProperty root "amount", tryStringProperty root "frequency" with
+ | Some code, Some amountText, Some frequencyText ->
+ match tryDecimal "amount" amountText, SipPolicy.parseFrequency frequencyText with
+ | Ok amount, Some frequency ->
+ Ok
+ {
+ InstrumentCode = code
+ Amount = amount
+ Frequency = frequency
+ }
+ | Error message, _ -> Error message
+ | _, None -> Error "frequency must be one of weekly, biweekly or monthly"
+ | _ ->
+ Error "instrumentCode, amount and frequency are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
let private invokeHandler handler next ctx = handler next ctx
+
let private unauthorized : HttpHandler =
setStatusCode 401
>=> setHttpHeader "WWW-Authenticate" "Bearer"
@@ -708,6 +763,53 @@ module App =
json (deposits |> List.map capitalDepositResponse) next ctx
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "capital deposit persistence failed" next ctx
+
+ let private createSipPlan (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseSipPlanCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateSipPlan(idempotencyKey, fundId, command) with
+ | SipPlanWriteResult.SipPlanCreated plan ->
+ return! invokeHandler (setStatusCode 201 >=> json (sipPlanResponse plan)) next ctx
+ | SipPlanWriteResult.SipPlanReplayed plan ->
+ return! invokeHandler (json (sipPlanResponse plan)) next ctx
+ | SipPlanWriteResult.SipPlanIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | SipPlanWriteResult.SipPlanInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_SIP_REQUEST" message) next ctx
+ | SipPlanWriteResult.SipPlanFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | SipPlanWriteResult.SipPlanInstrumentNotFound ->
+ return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "instrument code was not found in the instrument catalog") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed") next ctx
+ }
+
+ let private getSipPlans (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx
+ | true, fundId ->
+ try
+ match repository.GetFund fundId with
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ | Some _ ->
+ let plans = repository.GetSipPlans fundId
+ json (plans |> List.map sipPlanResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx
let private marketDataError (failure: MarketDataFailure) : HttpHandler =
let status, error, message =
match failure with
@@ -812,6 +914,8 @@ module App =
GET >=> routef "/funds/%s/positions" (getPositions repository)
POST >=> routef "/funds/%s/capital/deposit" (createCapitalDeposit repository)
GET >=> routef "/funds/%s/capital/deposits" (getCapitalDeposits repository)
+ POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository)
+ GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository)
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 444f578..8ca85e4 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -256,6 +256,35 @@ type RedemptionConfirmResult =
| RedemptionInvalidStatus
| RedemptionInvalid of string
+type SipPlanCommand =
+ {
+ InstrumentCode: string
+ Amount: decimal
+ Frequency: SipFrequency
+ }
+
+type SipPlanRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ InstrumentCode: string
+ Amount: decimal
+ Frequency: SipFrequency
+ Status: string
+ IsSynthetic: bool
+ AnchorDate: DateOnly
+ NextTradeDate: DateOnly
+ CreatedAt: DateTimeOffset
+ }
+
+type SipPlanWriteResult =
+ | SipPlanCreated of SipPlanRecord
+ | SipPlanReplayed of SipPlanRecord
+ | SipPlanIdempotencyConflict
+ | SipPlanInvalid of string
+ | SipPlanFundNotFound
+ | SipPlanInstrumentNotFound
+
type CapitalDepositCommand =
{
Amount: decimal
@@ -473,6 +502,27 @@ type FundRepository(connectionString: string) =
fund_id uuid NOT NULL REFERENCES funds(id),
created_at timestamptz NOT NULL DEFAULT now()
);
+
+ CREATE TABLE IF NOT EXISTS sip_plans (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ instrument_code text NOT NULL REFERENCES instruments(code),
+ amount numeric(20, 2) NOT NULL CHECK (amount > 0),
+ frequency text NOT NULL,
+ status text NOT NULL,
+ is_synthetic boolean NOT NULL,
+ anchor_date date NOT NULL,
+ next_trade_date date NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE IF NOT EXISTS sip_plan_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ plan_id uuid NOT NULL REFERENCES sip_plans(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
"""
let statusText status =
@@ -1285,6 +1335,109 @@ type FundRepository(connectionString: string) =
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+ let sipPlanRecordFromReader (reader: DbDataReader) : SipPlanRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ InstrumentCode = reader.GetString(2)
+ Amount = reader.GetDecimal(3)
+ Frequency =
+ match SipPolicy.parseFrequency (reader.GetString(4)) with
+ | Some frequency -> frequency
+ | None -> failwith "sip plan frequency is invalid"
+ Status = reader.GetString(5)
+ IsSynthetic = reader.GetBoolean(6)
+ AnchorDate = reader.GetFieldValue<DateOnly>(7)
+ NextTradeDate = reader.GetFieldValue<DateOnly>(8)
+ CreatedAt = reader.GetFieldValue<DateTimeOffset>(9)
+ }
+
+ let findSipPlan connection transaction planId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT id, fund_id, instrument_code, amount, frequency, status, is_synthetic, anchor_date, next_trade_date, created_at FROM sip_plans WHERE id = @plan_id"
+
+ addParameter command "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then Some(sipPlanRecordFromReader reader) else None
+
+ let findSipPlanIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT request_hash, fund_id, plan_id FROM sip_plan_idempotencies WHERE idempotency_key = @idempotency_key"
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then
+ Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2))
+ else
+ None
+
+ let insertSipPlan connection transaction (plan: SipPlanRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO sip_plans (id, fund_id, instrument_code, amount, frequency, status, is_synthetic, anchor_date, next_trade_date)
+ VALUES (@id, @fund_id, @instrument_code, @amount, @frequency, @status, @is_synthetic, @anchor_date, @next_trade_date)
+ RETURNING created_at
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box plan.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box plan.FundId) |> ignore
+ addParameter command "instrument_code" NpgsqlDbType.Text (box plan.InstrumentCode) |> ignore
+ addParameter command "amount" NpgsqlDbType.Numeric (box plan.Amount) |> ignore
+ addParameter command "frequency" NpgsqlDbType.Text (box (SipPolicy.frequencyText plan.Frequency)) |> ignore
+ addParameter command "status" NpgsqlDbType.Text (box plan.Status) |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box plan.IsSynthetic) |> ignore
+ addParameter command "anchor_date" NpgsqlDbType.Date (box plan.AnchorDate) |> ignore
+ addParameter command "next_trade_date" NpgsqlDbType.Date (box plan.NextTradeDate) |> ignore
+
+ use reader = command.ExecuteReader()
+ reader.Read() |> ignore
+ reader.GetFieldValue<DateTimeOffset>(0)
+
+ let insertSipPlanIdempotency connection transaction key requestHash planId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO sip_plan_idempotencies (idempotency_key, request_hash, plan_id, fund_id)
+ VALUES (@idempotency_key, @request_hash, @plan_id, @fund_id)
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+ addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore
+ addParameter command "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let sipPlanRequestHash (fundId: Guid) (command: SipPlanCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let code = if isNull command.InstrumentCode then "" else command.InstrumentCode
+
+ let payload =
+ String.concat
+ "|"
+ [
+ "sip-plan"
+ encoded (fundId.ToString("D"))
+ encoded code
+ (encoded (command.Amount.ToString("G29", invariant)))
+ (encoded (SipPolicy.frequencyText command.Frequency))
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
member _.EnsureSchema() =
use connection = new NpgsqlConnection(connectionString)
connection.Open()
@@ -1588,6 +1741,98 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.CreateSipPlan(idempotencyKey: string, fundId: Guid, command: SipPlanCommand) : SipPlanWriteResult =
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ SipPlanWriteResult.SipPlanInvalid "idempotency key cannot be empty"
+ else
+ match SipPolicy.validateAmount command.Amount with
+ | Error message -> SipPlanWriteResult.SipPlanInvalid message
+ | Ok() ->
+ let fingerprint = sipPlanRequestHash fundId command
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findSipPlanIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, planId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findSipPlan connection (Some transaction) planId with
+ | Some plan ->
+ transaction.Commit()
+ SipPlanWriteResult.SipPlanReplayed plan
+ | None ->
+ transaction.Rollback()
+ SipPlanWriteResult.SipPlanInvalid "idempotency record references a missing plan"
+ | Some _ ->
+ transaction.Rollback()
+ SipPlanWriteResult.SipPlanIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ SipPlanWriteResult.SipPlanFundNotFound
+ | Some isSynthetic ->
+ if instrumentExists connection (Some transaction) command.InstrumentCode then
+ let anchorDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ let plan: SipPlanRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ InstrumentCode = command.InstrumentCode
+ Amount = command.Amount
+ Frequency = command.Frequency
+ Status = "active"
+ IsSynthetic = isSynthetic
+ AnchorDate = anchorDate
+ NextTradeDate = SipPolicy.nextTradeDate command.Frequency anchorDate (anchorDate.AddDays 1)
+ CreatedAt = DateTimeOffset.UtcNow
+ }
+
+ let createdAt = insertSipPlan connection (Some transaction) plan
+ insertSipPlanIdempotency connection (Some transaction) idempotencyKey fingerprint plan.Id fundId
+ transaction.Commit()
+ SipPlanWriteResult.SipPlanCreated { plan with CreatedAt = createdAt }
+ else
+ transaction.Rollback()
+ SipPlanWriteResult.SipPlanInstrumentNotFound
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetSipPlans(fundId: Guid) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ "SELECT id, fund_id, instrument_code, amount, frequency, status, is_synthetic, anchor_date, next_trade_date, created_at FROM sip_plans WHERE fund_id = @fund_id ORDER BY created_at DESC, id"
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<SipPlanRecord>()
+
+ while reader.Read() do
+ records.Add(sipPlanRecordFromReader reader)
+
+ records |> Seq.toList
+
member _.CreateCapitalDeposit(idempotencyKey: string, fundId: Guid, command: CapitalDepositCommand) : CapitalDepositWriteResult =
if String.IsNullOrWhiteSpace idempotencyKey then
CapitalDepositWriteResult.CapitalDepositInvalid "idempotency key cannot be empty"
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
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index dc558c9..6fe1189 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -160,6 +160,17 @@ type RawPosition =
valuationCollectedAt: obj
}
+type RawSipPlan =
+ {
+ id: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextTradeDate: string
+ }
+
type RawRedemption =
{
id: string
@@ -240,6 +251,33 @@ type CreateCapitalPayload =
note: string
}
+type SipAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ }
+
+type CreateSipPayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ }
+
+type SipPlan =
+ {
+ id: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextTradeDate: string
+ }
+
type OrderDetail =
{
id: string
@@ -334,6 +372,12 @@ module Api =
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
+ [<Import("createSipPlan", "./src/api.js")>]
+ let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise<RawSipPlan> = jsNative
+
+ [<Import("getSipPlans", "./src/api.js")>]
+ let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -405,6 +449,17 @@ module Api =
positions = raw.positions |> Array.map decodePosition |> List.ofArray
}
+ let decodeSipPlan (raw: RawSipPlan) : SipPlan =
+ {
+ id = raw.id
+ instrumentCode = raw.instrumentCode
+ amount = raw.amount
+ frequency = raw.frequency
+ status = raw.status
+ anchorDate = raw.anchorDate
+ nextTradeDate = raw.nextTradeDate
+ }
+
let decodeRedemption (raw: RawRedemption) : RedemptionDetail =
{
id = raw.id
@@ -469,6 +524,14 @@ type Model =
capitalInFlight: bool
lastCapitalAttempt: CapitalAttempt option
capitalNote: string option
+ sipCode: string
+ sipAmount: string
+ sipFrequency: string
+ sipCreateSeq: int
+ sipReadSeq: int
+ sipInFlight: bool
+ lastSipAttempt: SipAttempt option
+ sipPlans: SipPlan list
error: string option
}
@@ -522,6 +585,15 @@ type Msg =
| CapitalDepositRequested
| CapitalDepositCompleted of requestId: int * amount: string
| CapitalDepositFailed of requestId: int * message: string
+ | SipCodeChanged of string
+ | SipAmountChanged of string
+ | SipFrequencyChanged of string
+ | SipPlanCreateRequested
+ | SipPlanCreateCompleted of requestId: int * fundId: string * plan: RawSipPlan
+ | SipPlanCreateFailed of requestId: int * fundId: string * message: string
+ | SipPlansReadRequested
+ | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array
+ | SipPlansReadFailed of requestId: int * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -598,6 +670,14 @@ let init () =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = 0
+ sipReadSeq = 0
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
}
@@ -698,6 +778,20 @@ let private createCapitalDepositCommand token fundId payload requestId =
(fun _ -> CapitalDepositCompleted(requestId, payload.amount))
(fun error -> CapitalDepositFailed(requestId, errorText error))
+let private createSipPlanCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createSipPlan token fundId payload)
+ ()
+ (fun plan -> SipPlanCreateCompleted(requestId, fundId, plan))
+ (fun error -> SipPlanCreateFailed(requestId, fundId, errorText error))
+
+let private readSipPlansCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getSipPlans token fundId)
+ ()
+ (fun plans -> SipPlansReadCompleted(requestId, plans))
+ (fun error -> SipPlansReadFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -747,6 +841,14 @@ let update message model =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = model.sipCreateSeq + 1
+ sipReadSeq = model.sipReadSeq + 1
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
},
Cmd.none
@@ -922,6 +1024,14 @@ let update message model =
capitalInFlight = false
lastCapitalAttempt = None
capitalNote = None
+ sipCode = ""
+ sipAmount = ""
+ sipFrequency = "weekly"
+ sipCreateSeq = model.sipCreateSeq + 1
+ sipReadSeq = model.sipReadSeq + 1
+ sipInFlight = false
+ lastSipAttempt = None
+ sipPlans = []
error = None
},
Cmd.ofMsg OrdersReadRequested
@@ -1316,6 +1426,109 @@ let update message model =
{ model with capitalInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | SipCodeChanged value ->
+ { model with sipCode = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipAmountChanged value ->
+ { model with sipAmount = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipFrequencyChanged value ->
+ { model with sipFrequency = value; error = None; lastSipAttempt = None }, Cmd.none
+ | SipPlanCreateRequested ->
+ let code = model.sipCode.Trim()
+ let amount = model.sipAmount.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif model.createdFund.IsNone then
+ { model with error = Some "请先创建一个基金" }, Cmd.none
+ elif code = "" then
+ { model with error = Some "请输入基金代码" }, Cmd.none
+ elif not (isValidCashText amount) || not (isPositiveCash amount) then
+ { model with error = Some "定投金额必须是大于零的两位小数金额,例如 200.00" }, Cmd.none
+ elif model.sipInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.sipCreateSeq + 1
+
+ let idempotencyKey =
+ match model.lastSipAttempt with
+ | Some attempt when
+ attempt.instrumentCode = code
+ && attempt.amount = amount
+ && attempt.frequency = model.sipFrequency
+ ->
+ attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ sipCode = code
+ sipAmount = amount
+ sipCreateSeq = requestId
+ sipInFlight = true
+ lastSipAttempt =
+ Some
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ amount = amount
+ frequency = model.sipFrequency
+ }
+ error = None
+ },
+ createSipPlanCommand
+ model.token
+ model.createdFund.Value.id
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ amount = amount
+ frequency = model.sipFrequency
+ }
+ requestId
+ | SipPlanCreateCompleted (requestId, fundId, plan) ->
+ if requestId = model.sipCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ sipInFlight = false
+ lastSipAttempt = None
+ error = None
+ },
+ Cmd.ofMsg SipPlansReadRequested
+ else
+ model, Cmd.none
+ | SipPlanCreateFailed (requestId, fundId, message) ->
+ if requestId = model.sipCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with sipInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | SipPlansReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.sipReadSeq + 1
+
+ { model with sipReadSeq = requestId; error = None },
+ readSipPlansCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ model, Cmd.none
+ | SipPlansReadCompleted (requestId, plans) ->
+ if requestId = model.sipReadSeq then
+ {
+ model with
+ sipPlans = plans |> Array.map Api.decodeSipPlan |> Array.toList
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | SipPlansReadFailed (requestId, message) ->
+ if requestId = model.sipReadSeq then
+ { model with error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -2025,6 +2238,116 @@ let private redeemPanel model dispatch =
]
]
+let private sipFrequencyText (frequency: string) =
+ if frequency = "weekly" then "每周"
+ elif frequency = "biweekly" then "每两周"
+ elif frequency = "monthly" then "每月"
+ else frequency
+
+let private sipPlanRow (plan: SipPlan) =
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (sipFrequencyText plan.frequency)) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "起投日 %s" plan.anchorDate) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "下次扣款 %s" plan.nextTradeDate) ]
+ Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ]
+ ]
+ ]
+
+let private sipPanel model dispatch =
+ Html.section [
+ prop.className "panel sip-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "07 / SIP" ]
+ Html.h2 "定期定额投资计划"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "SIP · schedule only" ]
+ ]
+ ]
+ Html.div [
+ prop.className "fund-form-row"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "基金代码"
+ Html.input [
+ prop.className "text-input sip-code-input"
+ prop.placeholder "六位基金代码"
+ prop.value model.sipCode
+ prop.onChange (fun value -> dispatch (SipCodeChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "每期金额(元)"
+ Html.input [
+ prop.className "text-input sip-amount-input"
+ prop.placeholder "例如 200.00"
+ prop.value model.sipAmount
+ prop.onChange (fun value -> dispatch (SipAmountChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "频率"
+ Html.select [
+ prop.className "text-input sip-frequency-input"
+ prop.value model.sipFrequency
+ prop.onChange (fun value -> dispatch (SipFrequencyChanged value))
+ prop.children [
+ Html.option [ prop.value "weekly"; prop.text "每周" ]
+ Html.option [ prop.value "biweekly"; prop.text "每两周" ]
+ Html.option [ prop.value "monthly"; prop.text "每月" ]
+ ]
+ ]
+ ]
+ ]
+ Html.button [
+ prop.className "primary-action sip-submit-action"
+ prop.disabled model.sipInFlight
+ prop.onClick (fun _ -> dispatch SipPlanCreateRequested)
+ prop.text ((if model.sipInFlight then "创建中..." else "创建定投计划"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "计划只登记扣款节奏与金额;到期执行将在后续接入,当前不扣款、不动份额与净值。"
+ ]
+ Html.div [
+ prop.className "sip-plans"
+ prop.children [
+ if List.isEmpty model.sipPlans then
+ Html.p [ prop.className "hint"; prop.text "暂无定投计划" ]
+ else
+ yield! (model.sipPlans |> List.map sipPlanRow)
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action sip-plans-refresh-action"
+ prop.onClick (fun _ -> dispatch SipPlansReadRequested)
+ prop.text "刷新计划"
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -2083,6 +2406,7 @@ let view model dispatch =
subscribePanel model dispatch
positionsPanel model dispatch
redeemPanel model dispatch
+ sipPanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index ab3235a..c5d4c2c 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -119,6 +119,22 @@ export function confirmRedemption(token, fundId, orderId, idempotencyKey) {
);
}
+export function createSipPlan(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getSipPlans(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token);
+}
+
export function createCapitalDeposit(token, fundId, payload) {
const body = `{"amount":${JSON.stringify(payload.amount)},"note":${JSON.stringify(payload.note || null)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/capital/deposit`, token, {
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index 1ab8fb8..89154cd 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -2010,3 +2010,135 @@ type CapitalDepositTests(fixture: PostgresFixture) =
Assert.Contains("\"amount\":\"111.00\"", response)
Assert.Contains("\"amount\":\"222.00\"", response)
Assert.Contains("第一笔", response)
+
+[<Collection("postgres")>]
+type SipPlanTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "定投测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "sip-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "定投测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "sip-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let availableCash fundId =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT available_cash FROM funds WHERE id = @fund_id"
+ let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ parameter.Value <- box fundId
+ command.ExecuteScalar() :?> decimal
+
+ let postPlan (fundId: Guid) (idempotencyKey: string) (code: string) (amount: string) (frequency: string) =
+ let body =
+ sprintf
+ "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}"
+ code
+ amount
+ frequency
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+
+ let getPlans (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/sip/plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``sip plan creation persists the plan without touching cash``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postPlan fundId key code "200.00" "weekly"
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"amount\":\"200.00\"", response)
+ Assert.Contains("\"frequency\":\"weekly\"", response)
+ Assert.Contains("\"status\":\"active\"", response)
+ Assert.Contains("\"nextTradeDate\":\"2026-09-28\"", response)
+ Assert.Equal(10000.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``sip plan replay and conflict follow the shared idempotency contract``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+
+ let firstStatus, firstResponse = postPlan fundId key code "150.00" "monthly"
+ let secondStatus, secondResponse = postPlan fundId key code "150.00" "monthly"
+
+ Assert.Equal(201, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+
+ let conflictingStatus, conflictingResponse = postPlan fundId key code "250.00" "weekly"
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse)
+
+ [<Fact>]
+ member _.``invalid sip amounts and frequencies are rejected``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+
+ for amount, frequency in [ "0.00", "weekly"; "-1.00", "weekly"; "1.234", "weekly"; "100.00", "daily" ] do
+ let key = fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))
+ let status, response = postPlan fundId key code amount frequency
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_SIP_REQUEST", response)
+
+ [<Fact>]
+ member _.``sip plan list exposes the stored plans``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let _, _ = postPlan fundId (fixture.Key(sprintf "sip-%s" (Guid.NewGuid().ToString("N")))) code "111.00" "biweekly"
+
+ let status, response = getPlans fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"instrumentCode\":\"" + code + "\"", response)
+ Assert.Contains("\"frequency\":\"biweekly\"", response)
+ Assert.Contains("\"amount\":\"111.00\"", response)
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)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 5226b65..dbaf4e5 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -707,3 +707,97 @@ module CapitalBoundaryTests =
Assert.Equal(None, cleared.createdFund)
Assert.False(cleared.capitalInFlight)
Assert.Equal(None, cleared.lastCapitalAttempt)
+
+module SipBoundaryTests =
+
+ let sampleFund () : FundSummary =
+ {
+ id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ name = "定投测试"
+ currency = "CNY"
+ initialCash = "10000.00"
+ initialUnitNav = "1.00000000"
+ isSynthetic = false
+ availableCash = "10000.00"
+ reservedCash = "0.00"
+ status = "active"
+ }
+
+ let sampleSipRaw () : RawSipPlan =
+ {
+ id = "aa1b2c3d-0000-0000-0000-000000000002"
+ instrumentCode = "000001"
+ amount = "200.00"
+ frequency = "weekly"
+ status = "active"
+ anchorDate = "2026-09-21"
+ nextTradeDate = "2026-09-28"
+ }
+
+ [<Fact>]
+ let ``sip plan decode keeps decimal strings and dates untouched`` () =
+ let decoded = Api.decodeSipPlan (sampleSipRaw ())
+
+ Assert.Equal("000001", decoded.instrumentCode)
+ Assert.Equal("200.00", decoded.amount)
+ Assert.Equal("weekly", decoded.frequency)
+ Assert.Equal("2026-09-28", decoded.nextTradeDate)
+ Assert.Equal("active", decoded.status)
+
+ [<Fact>]
+ let ``sip plan request validates inputs and guards in-flight duplicates`` () =
+ let noFund, _ = update SipPlanCreateRequested { init () with token = "qa-token" }
+
+ Assert.Equal(0, noFund.sipCreateSeq)
+ Assert.False(noFund.sipInFlight)
+
+ let noToken, _ =
+ update SipPlanCreateRequested { init () with createdFund = Some(sampleFund ()) }
+
+ Assert.Equal(Some "请输入 API token", noToken.error)
+
+ let invalidAmount, _ =
+ update SipPlanCreateRequested
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCode = "000001"
+ sipAmount = "0.00" }
+
+ Assert.Equal(Some "定投金额必须是大于零的两位小数金额,例如 200.00", invalidAmount.error)
+ Assert.Equal(0, invalidAmount.sipCreateSeq)
+
+ let started, _ =
+ update SipPlanCreateRequested
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCode = "000001"
+ sipAmount = "200.00" }
+
+ Assert.Equal(1, started.sipCreateSeq)
+ Assert.True(started.sipInFlight)
+ Assert.Equal(32, started.lastSipAttempt.Value.idempotencyKey.Length)
+
+ let duplicate, _ = update SipPlanCreateRequested started
+
+ Assert.Equal(started.sipCreateSeq, duplicate.sipCreateSeq)
+
+ [<Fact>]
+ let ``stale sip completions do not overwrite newer state`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ createdFund = Some(sampleFund ())
+ sipCreateSeq = 2
+ sipInFlight = true
+ sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] }
+
+ let stale, _ = update (SipPlanCreateCompleted(1, (sampleFund ()).id, sampleSipRaw ())) model
+
+ Assert.True(stale.sipInFlight)
+ Assert.Equal(1, stale.sipPlans.Length)
+
+ let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 }
+
+ Assert.Equal(1, staleList.sipPlans.Length)