summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs89
-rw-r--r--src/FundLab.Api/Persistence.fs233
-rw-r--r--src/FundLab.Domain/Capital.fs27
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Web/App.fs134
-rw-r--r--src/FundLab.Web/src/api.js12
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
+ });
+}