diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:44:07 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:44:07 +0800 |
| commit | 205e2a592bf781f08fe23f3577cdfe74ff9a076a (patch) | |
| tree | 05aad6f87ca8f6c78fbc2d4c4d0d89356439936a /src | |
| parent | b8811922f4e167d50efb8817da35a12d8a9d653b (diff) | |
| download | fund-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')
| -rw-r--r-- | src/FundLab.Api/App.fs | 89 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 233 | ||||
| -rw-r--r-- | src/FundLab.Domain/Capital.fs | 27 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 134 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 12 |
6 files changed, 496 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 9712fc6..55a29f4 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -131,6 +131,16 @@ type FundPositionsResponse = positions: FundPositionResponse list } +type CapitalDepositResponse = + { + id: Guid + fundId: Guid + amount: string + note: string option + isSynthetic: bool + createdAt: string + } + type ApiErrorResponse = { error: string @@ -261,6 +271,16 @@ module App = isSynthetic = order.IsSynthetic } + let private capitalDepositResponse (deposit: CapitalDepositRecord) : CapitalDepositResponse = + { + id = deposit.Id + fundId = deposit.FundId + amount = cashText deposit.Amount + note = deposit.Note + isSynthetic = deposit.IsSynthetic + createdAt = timestampText deposit.CreatedAt + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -369,6 +389,28 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseCapitalDepositCommand (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 "amount" with + | None -> Error "amount is required" + | Some amountText -> + match tryDecimal "amount" amountText with + | Error message -> Error message + | Ok amount -> + Ok + { + Amount = amount + Note = tryStringProperty root "note" + } + with + | :? JsonException -> Error "request body must be valid JSON" + let private invokeHandler handler next ctx = handler next ctx let private unauthorized : HttpHandler = @@ -621,6 +663,51 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "redemption confirmation failed" next ctx + + let private createCapitalDeposit (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_CAPITAL_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 parseCapitalDepositCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_CAPITAL_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateCapitalDeposit(idempotencyKey, fundId, command) with + | CapitalDepositWriteResult.CapitalDepositCreated deposit -> + return! invokeHandler (setStatusCode 201 >=> json (capitalDepositResponse deposit)) next ctx + | CapitalDepositWriteResult.CapitalDepositReplayed deposit -> + return! invokeHandler (json (capitalDepositResponse deposit)) next ctx + | CapitalDepositWriteResult.CapitalDepositIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | CapitalDepositWriteResult.CapitalDepositInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_CAPITAL_REQUEST" message) next ctx + | CapitalDepositWriteResult.CapitalDepositFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "capital deposit persistence failed") next ctx + } + + let private getCapitalDeposits (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 deposits = repository.GetCapitalDeposits fundId + json (deposits |> List.map capitalDepositResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "capital deposit persistence failed" next ctx let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -723,6 +810,8 @@ module App = GET >=> routef "/funds/%s/redemptions" (getRedemptions repository) POST >=> routef "/funds/%s/redemptions/%s/confirm" (fun (fundId, orderId) -> confirmRedemption repository fundId orderId) GET >=> routef "/funds/%s/positions" (getPositions repository) + POST >=> routef "/funds/%s/capital/deposit" (createCapitalDeposit repository) + GET >=> routef "/funds/%s/capital/deposits" (getCapitalDeposits 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 85396d5..444f578 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -256,6 +256,29 @@ type RedemptionConfirmResult = | RedemptionInvalidStatus | RedemptionInvalid of string +type CapitalDepositCommand = + { + Amount: decimal + Note: string option + } + +type CapitalDepositRecord = + { + Id: Guid + FundId: Guid + Amount: decimal + Note: string option + IsSynthetic: bool + CreatedAt: DateTimeOffset + } + +type CapitalDepositWriteResult = + | CapitalDepositCreated of CapitalDepositRecord + | CapitalDepositReplayed of CapitalDepositRecord + | CapitalDepositIdempotencyConflict + | CapitalDepositInvalid of string + | CapitalDepositFundNotFound + type FundRepository(connectionString: string) = let cashMaximum = 999999999999999999.99m let unitNavMaximum = 99999999999999999999.99999999m @@ -433,6 +456,23 @@ type FundRepository(connectionString: string) = ); ALTER TABLE fund_positions ADD COLUMN IF NOT EXISTS reserved_units numeric(28, 8) NOT NULL DEFAULT 0; + + CREATE TABLE IF NOT EXISTS fund_capital_deposits ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + amount numeric(20, 2) NOT NULL CHECK (amount > 0), + note text NULL, + is_synthetic boolean NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS fund_capital_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + deposit_id uuid NOT NULL REFERENCES fund_capital_deposits(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); """ let statusText status = @@ -1148,6 +1188,103 @@ type FundRepository(connectionString: string) = else Ok() + let capitalDepositRecordFromReader (reader: DbDataReader) : CapitalDepositRecord = + { + Id = reader.GetGuid(0) + FundId = reader.GetGuid(1) + Amount = reader.GetDecimal(2) + Note = readStringOption reader 3 + IsSynthetic = reader.GetBoolean(4) + CreatedAt = reader.GetFieldValue<DateTimeOffset>(5) + } + + let findCapitalDeposit connection transaction depositId = + use command = + commandWithTransaction + connection + transaction + "SELECT id, fund_id, amount, note, is_synthetic, created_at FROM fund_capital_deposits WHERE id = @deposit_id" + + addParameter command "deposit_id" NpgsqlDbType.Uuid (box depositId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(capitalDepositRecordFromReader reader) else None + + let findCapitalDepositIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + "SELECT request_hash, fund_id, deposit_id FROM fund_capital_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 insertCapitalDeposit connection transaction (deposit: CapitalDepositRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO fund_capital_deposits (id, fund_id, amount, note, is_synthetic) + VALUES (@id, @fund_id, @amount, @note, @is_synthetic) + RETURNING created_at + """ + + addParameter command "id" NpgsqlDbType.Uuid (box deposit.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box deposit.FundId) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box deposit.Amount) |> ignore + + let noteParameter = + match deposit.Note with + | Some note -> box note + | None -> box DBNull.Value + + addParameter command "note" NpgsqlDbType.Text noteParameter |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box deposit.IsSynthetic) |> ignore + + use reader = command.ExecuteReader() + reader.Read() |> ignore + reader.GetFieldValue<DateTimeOffset>(0) + + let insertCapitalDepositIdempotency connection transaction key requestHash depositId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO fund_capital_idempotencies (idempotency_key, request_hash, deposit_id, fund_id) + VALUES (@idempotency_key, @request_hash, @deposit_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "deposit_id" NpgsqlDbType.Uuid (box depositId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let capitalDepositRequestHash (fundId: Guid) (command: CapitalDepositCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let note = if command.Note.IsNone then "" else command.Note.Value + + let payload = + String.concat + "|" + [ + "capital-deposit" + encoded (fundId.ToString("D")) + (encoded (command.Amount.ToString("G29", invariant))) + (encoded note) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + member _.EnsureSchema() = use connection = new NpgsqlConnection(connectionString) connection.Open() @@ -1451,6 +1588,102 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.CreateCapitalDeposit(idempotencyKey: string, fundId: Guid, command: CapitalDepositCommand) : CapitalDepositWriteResult = + if String.IsNullOrWhiteSpace idempotencyKey then + CapitalDepositWriteResult.CapitalDepositInvalid "idempotency key cannot be empty" + else + match CapitalPolicy.validateDeposit command.Amount with + | Error message -> CapitalDepositWriteResult.CapitalDepositInvalid message + | Ok() -> + let fingerprint = capitalDepositRequestHash 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 findCapitalDepositIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, depositId) + when existingHash = fingerprint && existingFundId = fundId -> + match findCapitalDeposit connection (Some transaction) depositId with + | Some deposit -> + transaction.Commit() + CapitalDepositWriteResult.CapitalDepositReplayed deposit + | None -> + transaction.Rollback() + CapitalDepositWriteResult.CapitalDepositInvalid "idempotency record references a missing deposit" + | Some _ -> + transaction.Rollback() + CapitalDepositWriteResult.CapitalDepositIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + CapitalDepositWriteResult.CapitalDepositFundNotFound + | Some isSynthetic -> + use cashCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE funds SET available_cash = available_cash + @amount WHERE id = @fund_id" + + addParameter cashCommand "amount" NpgsqlDbType.Numeric (box command.Amount) |> ignore + addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + if cashCommand.ExecuteNonQuery() = 0 then + transaction.Rollback() + CapitalDepositWriteResult.CapitalDepositFundNotFound + else + let deposit: CapitalDepositRecord = + { + Id = Guid.NewGuid() + FundId = fundId + Amount = command.Amount + Note = command.Note + IsSynthetic = isSynthetic + CreatedAt = DateTimeOffset.UtcNow + } + + let createdAt = insertCapitalDeposit connection (Some transaction) deposit + insertCapitalDepositIdempotency connection (Some transaction) idempotencyKey fingerprint deposit.Id fundId + transaction.Commit() + CapitalDepositWriteResult.CapitalDepositCreated { deposit with CreatedAt = createdAt } + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetCapitalDeposits(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + "SELECT id, fund_id, amount, note, is_synthetic, created_at FROM fund_capital_deposits 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<CapitalDepositRecord>() + + while reader.Read() do + records.Add(capitalDepositRecordFromReader reader) + + records |> Seq.toList + member _.CreateRedemptionOrder(idempotencyKey: string, fundId: Guid, command: RedemptionCommand) : RedemptionWriteResult = if String.IsNullOrWhiteSpace idempotencyKey then RedemptionWriteResult.RedemptionInvalid "idempotency key cannot be empty" diff --git a/src/FundLab.Domain/Capital.fs b/src/FundLab.Domain/Capital.fs new file mode 100644 index 0000000..b2ae697 --- /dev/null +++ b/src/FundLab.Domain/Capital.fs @@ -0,0 +1,27 @@ +namespace FundLab.Domain + +open System + +module CapitalPolicy = + let cashMaximum = 999999999999999999.99m + + let validateDeposit (amount: decimal) : Result<unit, string> = + if amount <= 0m then + Error "deposit amount must be positive" + elif Decimal.Round(amount, 2) <> amount then + Error "deposit amount exceeds cash precision" + elif amount > cashMaximum then + Error "deposit amount exceeds database precision" + else + Ok () + + let settle (availableCash: decimal) (amount: decimal) : Result<decimal, string> = + match validateDeposit amount with + | Error message -> Error message + | Ok() -> + let updated = availableCash + amount + + if updated > cashMaximum then + Error "available cash exceeds database precision" + else + Ok updated diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index a2c5c24..0daf3ea 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -9,6 +9,7 @@ <Compile Include="Domain.fs" /> <Compile Include="Ledger.fs" /> <Compile Include="Redemption.fs" /> + <Compile Include="Capital.fs" /> <Compile Include="Performance.fs" /> </ItemGroup> </Project> 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 + }); +} |
