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 | |
| 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).
| -rw-r--r-- | src/FundLab.Api/App.fs | 104 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 245 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/Sip.fs | 94 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 324 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 132 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 61 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 94 |
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) |
