From f4ab0b08d7648914f6fd049fa1887c0422f69318 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 12:03:39 +0800 Subject: Add SIP plan first slice (3d-5) Introduce scheduled-investment plans as a schedule-only slice with no cash, position or NAV side effects. Domain gains SipPolicy: amount validation (positive, two-decimal, overflow-checked), frequency parsing/round-trip (weekly/biweekly/monthly), nextTradeDate generating the next due trade date off the plan anchor with month-end clamping and weekend roll-forward, plus the isDue/executionNetAmount contract the future executor will implement (execution itself stays unimplemented and is marked as such; nothing debits cash in this slice). Persistence stores plans in sip_plans with anchor/next trade dates and sip_plan_idempotencies under the shared replay/conflict contract; the API exposes POST/GET /funds/{id}/sip/plans behind the bearer token. The frontend adds a 07/SIP panel (code, amount, frequency select, plan list with next debit date) and Web tests cover decoding, validation, in-flight guards and stale completions. Choice note: first debit is scheduled strictly after the anchor date (creating a plan today never debits today). --- src/FundLab.Api/App.fs | 104 ++++++++++ src/FundLab.Api/Persistence.fs | 245 +++++++++++++++++++++++ src/FundLab.Domain/FundLab.Domain.fsproj | 1 + src/FundLab.Domain/Sip.fs | 94 +++++++++ src/FundLab.Web/App.fs | 324 +++++++++++++++++++++++++++++++ src/FundLab.Web/src/api.js | 16 ++ 6 files changed, 784 insertions(+) create mode 100644 src/FundLab.Domain/Sip.fs (limited to 'src') 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(7) + NextTradeDate = reader.GetFieldValue(8) + CreatedAt = reader.GetFieldValue(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(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() + + 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 @@ + diff --git a/src/FundLab.Domain/Sip.fs b/src/FundLab.Domain/Sip.fs new file mode 100644 index 0000000..2db7fbd --- /dev/null +++ b/src/FundLab.Domain/Sip.fs @@ -0,0 +1,94 @@ +namespace FundLab.Domain + +open System + +type SipFrequency = + | Weekly + | BiWeekly + | Monthly + +module SipPolicy = + let cashMaximum = 999999999999999999.99m + + let validateAmount (amount: decimal) : Result = + if amount <= 0m then + Error "sip amount must be positive" + elif Decimal.Round(amount, 2) <> amount then + Error "sip amount exceeds cash precision" + elif amount > cashMaximum then + Error "sip amount exceeds database precision" + else + Ok () + + let parseFrequency (text: string) : SipFrequency option = + match text with + | null -> None + | "weekly" -> Some Weekly + | "biweekly" -> Some BiWeekly + | "monthly" -> Some Monthly + | _ -> None + + let frequencyText (frequency: SipFrequency) : string = + match frequency with + | Weekly -> "weekly" + | BiWeekly -> "biweekly" + | Monthly -> "monthly" + + let private isWeekend (date: DateOnly) = + date.DayOfWeek = DayOfWeek.Saturday || date.DayOfWeek = DayOfWeek.Sunday + + let private rollToWeekday (date: DateOnly) : DateOnly = + let mutable candidate = date + + while isWeekend candidate do + candidate <- candidate.AddDays 1 + + candidate + + let private intervalDays (frequency: SipFrequency) : int = + match frequency with + | Weekly -> 7 + | BiWeekly -> 14 + | Monthly -> 0 + + let private addMonthsPreservingDay (anchor: DateOnly) (months: int) : DateOnly = + let targetMonthIndex = anchor.Year * 12 + (anchor.Month - 1) + months + let year = targetMonthIndex / 12 + let month = targetMonthIndex % 12 + 1 + let daysInTargetMonth = DateTime.DaysInMonth(year, month) + DateOnly(year, month, min anchor.Day daysInTargetMonth) + + /// Next due trade date for a plan: the first run date of the plan's cadence that is + /// on or after `fromDate`, rolled forward off weekends (no-lookahead weekday rule). + let nextTradeDate (frequency: SipFrequency) (anchorDate: DateOnly) (fromDate: DateOnly) : DateOnly = + match frequency with + | Weekly + | BiWeekly -> + let interval = intervalDays frequency + let anchorTicks = anchorDate.DayNumber + let fromTicks = fromDate.DayNumber + let steps = max 0 ((fromTicks - anchorTicks + interval - 1) / interval) + rollToWeekday (anchorDate.AddDays(steps * interval)) + | Monthly -> + let monthsBetween = + (fromDate.Year - anchorDate.Year) * 12 + (fromDate.Month - anchorDate.Month) + + let candidateMonths = + if fromDate.Day <= anchorDate.Day || monthsBetween < 0 then + monthsBetween + else + monthsBetween + 1 + + let months = max 0 candidateMonths + rollToWeekday (addMonthsPreservingDay anchorDate months) + + /// Execution is out of scope for this slice. These pure helpers define the + /// due/settlement contract that the (future) executor will implement: when a + /// plan is due, the net amount debited equals the plan amount exactly, and no + /// units, NAV or realized income are touched until that executor lands (3d-6+). + let isDue (nextTradeDate: DateOnly) (today: DateOnly) : bool = today >= nextTradeDate + + let executionNetAmount (plannedAmount: decimal) : Result = + match validateAmount plannedAmount with + | Error message -> Error message + | Ok() -> Ok plannedAmount 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 = [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative + [] + let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise = jsNative + + [] + let getSipPlans (token: string) (fundId: string) : JS.Promise = 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, { -- cgit v1.2.3