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.Web/App.fs | 324 +++++++++++++++++++++++++++++++++++++++++++++ src/FundLab.Web/src/api.js | 16 +++ 2 files changed, 340 insertions(+) (limited to 'src/FundLab.Web') 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