From 205e2a592bf781f08fe23f3577cdfe74ff9a076a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 11:44:07 +0800 Subject: Add additional capital deposit flow (3d-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deposits credit a fund's available cash directly: Domain gains CapitalPolicy (positive, two-decimal, overflow-checked amounts plus a settle function that never touches units, NAV or realized P&L). Persistence records deposits in fund_capital_deposits with a fund_capital_idempotencies table, crediting cash and the deposit row atomically under the same replay/conflict semantics as orders. The API exposes POST /funds/{id}/capital/deposit and GET /funds/{id}/capital/deposits behind the bearer token and idempotency keys. The frontend adds an '追加资金' entry on the fund cash card with in-place balance refresh, and repeats of the same amount reuse the idempotency key so double submits credit cash exactly once. Browser QA extends the J-series (J12-J18): invalid-amount rejection without POST, single credited deposit with a 32-hex key, instant balance refresh, unchanged units/cost proving no realized-income impact, and same-key replay crediting only once. --- src/FundLab.Web/App.fs | 134 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) (limited to 'src/FundLab.Web/App.fs') diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 7eb4469..dc558c9 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -227,6 +227,19 @@ type CreateRedemptionPayload = feeAmount: string } +type CapitalAttempt = + { + idempotencyKey: string + amount: string + } + +type CreateCapitalPayload = + { + idempotencyKey: string + amount: string + note: string + } + type OrderDetail = { id: string @@ -318,6 +331,9 @@ module Api = [] let confirmRedemption (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise = jsNative + [] + let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -448,6 +464,11 @@ type Model = redemptionConfirmSeq: int redemptionConfirmInFlight: bool lastRedemptionConfirmAttempt: RedemptionConfirmAttempt option + capitalAmount: string + capitalSeq: int + capitalInFlight: bool + lastCapitalAttempt: CapitalAttempt option + capitalNote: string option error: string option } @@ -497,6 +518,10 @@ type Msg = | RedemptionConfirmRequested of orderId: string | RedemptionConfirmCompleted of requestId: int * orderId: string * order: RawRedemption | RedemptionConfirmFailed of requestId: int * orderId: string * message: string + | CapitalAmountChanged of string + | CapitalDepositRequested + | CapitalDepositCompleted of requestId: int * amount: string + | CapitalDepositFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -568,6 +593,11 @@ let init () = redemptionConfirmSeq = 0 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = None + capitalAmount = "" + capitalSeq = 0 + capitalInFlight = false + lastCapitalAttempt = None + capitalNote = None error = None } @@ -661,6 +691,13 @@ let private confirmRedemptionCommand token fundId orderId idempotencyKey request (fun order -> RedemptionConfirmCompleted(requestId, orderId, order)) (fun error -> RedemptionConfirmFailed(requestId, orderId, errorText error)) +let private createCapitalDepositCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createCapitalDeposit token fundId payload) + () + (fun _ -> CapitalDepositCompleted(requestId, payload.amount)) + (fun error -> CapitalDepositFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -705,6 +742,11 @@ let update message model = redemptionConfirmSeq = model.redemptionConfirmSeq + 1 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = None + capitalAmount = "" + capitalSeq = model.capitalSeq + 1 + capitalInFlight = false + lastCapitalAttempt = None + capitalNote = None error = None }, Cmd.none @@ -875,6 +917,11 @@ let update message model = redemptionConfirmSeq = model.redemptionConfirmSeq + 1 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = None + capitalAmount = "" + capitalSeq = model.capitalSeq + 1 + capitalInFlight = false + lastCapitalAttempt = None + capitalNote = None error = None }, Cmd.ofMsg OrdersReadRequested @@ -1215,6 +1262,60 @@ let update message model = Cmd.ofMsg RedemptionsReadRequested else model, Cmd.none + | CapitalAmountChanged value -> + { model with capitalAmount = value; error = None; lastCapitalAttempt = None }, Cmd.none + | CapitalDepositRequested -> + let amount = model.capitalAmount.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 not (isValidCashText amount) || not (isPositiveCash amount) then + { model with error = Some "追加金额必须是大于零的两位小数金额,例如 1000.00" }, Cmd.none + elif model.capitalInFlight then + model, Cmd.none + else + let requestId = model.capitalSeq + 1 + + let idempotencyKey = + match model.lastCapitalAttempt with + | Some attempt when attempt.amount = amount -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + + { + model with + capitalAmount = amount + capitalSeq = requestId + capitalInFlight = true + lastCapitalAttempt = Some { idempotencyKey = idempotencyKey; amount = amount } + error = None + }, + createCapitalDepositCommand + model.token + model.createdFund.Value.id + { + idempotencyKey = idempotencyKey + amount = amount + note = "追加资金" + } + requestId + | CapitalDepositCompleted (requestId, amount) -> + if requestId = model.capitalSeq then + { + model with + capitalInFlight = false + capitalNote = Some(sprintf "追加成功 + %s(现金余额已刷新)" amount) + error = None + }, + Cmd.ofMsg FundReadRequested + else + model, Cmd.none + | CapitalDepositFailed (requestId, message) -> + if requestId = model.capitalSeq then + { model with capitalInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -1544,6 +1645,39 @@ let private fundPanel model dispatch = prop.className "hint" prop.text "创建成功后,这里会展示服务端返回的基金档案,可通过重新读取复核持久化结果。" ] + match model.createdFund with + | Some _ -> + Html.div [ + prop.className "capital-block" + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "追加资金(元)" + Html.input [ + prop.className "text-input capital-amount-input" + prop.placeholder "例如 1000.00" + prop.value model.capitalAmount + prop.onChange (fun value -> dispatch (CapitalAmountChanged value)) + ] + ] + ] + Html.button [ + prop.className "primary-action capital-deposit-action" + prop.disabled model.capitalInFlight + prop.onClick (fun _ -> dispatch CapitalDepositRequested) + prop.text ((if model.capitalInFlight then "追加中..." else "追加资金"): string) + ] + ] + ] + Html.p [ + prop.className "hint capital-result" + prop.text + (match model.capitalNote with + | Some note -> note + | None -> "追加资金直接计入可用现金,不产生份额、不分摊成本、不计入已实现收益。") + ] + | None -> Html.none ] ] -- cgit v1.2.3