summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 11:44:07 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 11:44:07 +0800
commit205e2a592bf781f08fe23f3577cdfe74ff9a076a (patch)
tree05aad6f87ca8f6c78fbc2d4c4d0d89356439936a /src/FundLab.Web
parentb8811922f4e167d50efb8817da35a12d8a9d653b (diff)
downloadfund-lab-205e2a592bf781f08fe23f3577cdfe74ff9a076a.tar.gz
Add additional capital deposit flow (3d-4)
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.
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs134
-rw-r--r--src/FundLab.Web/src/api.js12
2 files changed, 146 insertions, 0 deletions
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 =
[<Import("confirmRedemption", "./src/api.js")>]
let confirmRedemption (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise<RawRedemption> = jsNative
+ [<Import("createCapitalDeposit", "./src/api.js")>]
+ let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = 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
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 743229c..ab3235a 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -118,3 +118,15 @@ export function confirmRedemption(token, fundId, orderId, idempotencyKey) {
}
);
}
+
+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, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}