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. --- qa/driver/browser-test.js | 63 +++++++- src/FundLab.Api/App.fs | 89 ++++++++++++ src/FundLab.Api/Persistence.fs | 233 ++++++++++++++++++++++++++++++ src/FundLab.Domain/Capital.fs | 27 ++++ src/FundLab.Domain/FundLab.Domain.fsproj | 1 + src/FundLab.Web/App.fs | 134 +++++++++++++++++ src/FundLab.Web/src/api.js | 12 ++ tests/FundLab.Api.Tests/OrderTests.fs | 130 +++++++++++++++++ tests/FundLab.Domain.Tests/DomainTests.fs | 21 +++ tests/FundLab.Web.Tests/BoundaryTests.fs | 72 +++++++++ 10 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 src/FundLab.Domain/Capital.fs diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 677ae54..acc576d 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -13,6 +13,8 @@ const confirmPosts = []; const confirmPostStatuses = []; const redemptionPosts = []; const redemptionConfirmResponses = []; +const capitalPosts = []; +const capitalPostStatuses = []; let positionGets = 0; let lastPositionsResponse = null; let fundPostResponses = 0; @@ -109,6 +111,7 @@ async function summaryLabelExists(page, label, name) { } catch {} } if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); + if (r.url().includes("/capital") && r.request().method() === "POST") capitalPostStatuses.push(r.status()); if (r.url().includes("/confirm") && r.url().includes("/redemptions") && r.request().method() === "POST") { try { lastRedemptionConfirmResponse = await r.json(); @@ -126,7 +129,7 @@ async function summaryLabelExists(page, label, name) { lastPositionsResponse = await r.json(); } catch {} } - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url())) { + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -143,6 +146,9 @@ async function summaryLabelExists(page, label, name) { if (r.url().includes("/redemptions") && r.method() === "POST" && !r.url().includes("/confirm")) { redemptionPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } + if (r.url().includes("/capital") && r.method() === "POST") { + capitalPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); + } }); page.on("console", (m) => { if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text()); @@ -167,6 +173,7 @@ async function summaryLabelExists(page, label, name) { await orderScenario(page); await confirmScenario(page); await redeemScenario(page); + await capitalScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -280,6 +287,60 @@ async function redeemScenario(page) { await page.screenshot({ path: SHOTS + "/13-redemption-settled.png" }); } + +async function capitalScenario(page) { + // J12-J17: 追加资金(接 K 场景:现金 4937.99,持仓 50.00000000/成本 59.51 不变) + const initialCash = "4937.99"; + await page.waitForSelector(".capital-deposit-action", { timeout: 10000 }); + + await page.fill(".capital-amount-input", "0.00"); + await page.click(".capital-deposit-action"); + await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J12 非法金额(0.00)拒绝"); + await page.fill(".capital-amount-input", "abc"); + await page.click(".capital-deposit-action"); + await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J13 非法金额(abc)拒绝"); + check("J14 校验阶段无追加 POST", capitalPosts.length === 0, `posts=${capitalPosts.length}`); + + await page.fill(".capital-amount-input", "500.00"); + await page.click(".capital-deposit-action"); + for (let i = 0; i < 100 && capitalPostStatuses.length === 0; i++) { + await page.waitForTimeout(100); + } + check("J15 追加 POST 恰好 1 次且幂等键 32 位十六进制", capitalPosts.length === 1 && /^[0-9a-f]{32}$/.test(capitalPosts[0].key), JSON.stringify(capitalPosts)); + await page.waitForFunction( + (want) => { + const rows = Array.from(document.querySelectorAll(".fund-detail-row")); + return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === "可用现金" && (r.querySelector(".fund-detail-value")?.textContent || "").trim() === want); + }, + "5437.99", + { timeout: 15000 } + ); + check("J16 追加后现金即时刷新 5437.99", true); + + // 现金守恒:持仓份额与成本不变,现金差等于追加额 + const summary = ((await page.textContent(".fund-summary")) || ""); + const holdings = ((await page.textContent(".positions-panel")) || ""); + check( + "J17 追加不计入已实现收益(份额与成本不变)", + holdings.includes("份额 50.00000000") && holdings.includes("成本 59.51") && summary.includes("5437.99"), + holdings.slice(0, 200) + ); + + // 幂等 ×1:同金额重试复用键 → replay 200,现金只入账一次 + await page.click(".capital-deposit-action"); + for (let i = 0; i < 100 && capitalPostStatuses.length < 2; i++) { + await page.waitForTimeout(100); + } + await page.waitForTimeout(500); + const summaryAfterRetry = ((await page.textContent(".fund-summary")) || ""); + check( + "J18 同键重放只入账一次", + capitalPostStatuses.length === 2 && capitalPosts[0].key === capitalPosts[1].key && summaryAfterRetry.includes("5437.99"), + JSON.stringify({ statuses: capitalPostStatuses, keys: capitalPosts.map((p) => p.key), summary: summaryAfterRetry.slice(0, 160) }) + ); + await page.screenshot({ path: SHOTS + "/14-capital-deposit.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); 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(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(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() + + 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 = + 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 = + 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 @@ + 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 ] ] 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 + }); +} diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index 4fc1da0..1ab8fb8 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -1880,3 +1880,133 @@ type RedemptionTests(fixture: PostgresFixture) = Assert.Contains("\"instrumentCode\":\"" + code + "\"", listResponse) Assert.Contains("\"status\":\"confirmed\"", listResponse) Assert.Contains("\"confirmedProceeds\":\"99.60\"", listResponse) + +[] +type CapitalDepositTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund (initialCash: decimal) = + let command = + { + Name = "追加资金测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "capital-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let postDeposit (fundId: Guid) (idempotencyKey: string) (amount: string) (note: string) = + let body = sprintf "{\"amount\":\"%s\",\"note\":\"%s\"}" amount note + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/capital/deposit" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let availableCash fundId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT available_cash FROM funds WHERE id = @fund_id" + let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box fundId + command.ExecuteScalar() :?> decimal + + let depositCount fundId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT count(*) FROM fund_capital_deposits WHERE fund_id = @fund_id" + let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box fundId + command.ExecuteScalar() :?> int64 + + [] + member _.``capital deposit credits available cash and records the deposit``() = + let fundId = createFund 10000.00m + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + + let status, response = postDeposit fundId key "500.00" "首次追加" + + Assert.Equal(201, status) + Assert.Contains("\"amount\":\"500.00\"", response) + Assert.Equal(10500.00m, availableCash fundId) + Assert.Equal(1L, depositCount fundId) + + [] + member _.``capital deposit replay with the same key credits cash exactly once``() = + let fundId = createFund 10000.00m + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + + let firstStatus, firstResponse = postDeposit fundId key "300.00" "重复追加" + let secondStatus, secondResponse = postDeposit fundId key "300.00" "重复追加" + + Assert.Equal(201, firstStatus) + Assert.Equal(200, secondStatus) + Assert.Equal(firstResponse, secondResponse) + Assert.Equal(10300.00m, availableCash fundId) + Assert.Equal(1L, depositCount fundId) + + let conflictingStatus, conflictingResponse = postDeposit fundId key "100.00" "不同金额" + + Assert.Equal(409, conflictingStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse) + Assert.Equal(10300.00m, availableCash fundId) + + [] + member _.``invalid deposit amounts are rejected without any cash movement``() = + let fundId = createFund 10000.00m + + for amount in [ "0.00"; "-5.00"; "1.234" ] do + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + let status, response = postDeposit fundId key amount "非法" + Assert.Equal(400, status) + Assert.Contains("INVALID_CAPITAL_REQUEST", response) + + Assert.Equal(10000.00m, availableCash fundId) + Assert.Equal(0L, depositCount fundId) + + [] + member _.``deposit for an unknown fund returns 404``() = + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + let status, response = postDeposit (Guid.NewGuid()) key "100.00" "无基金" + + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", response) + + [] + member _.``deposit history is exposed via GET``() = + let fundId = createFund 10000.00m + let _, _ = postDeposit fundId (fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N")))) "111.00" "第一笔" + let _, _ = postDeposit fundId (fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N")))) "222.00" "第二笔" + + let status, response = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/capital/deposits" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"amount\":\"111.00\"", response) + Assert.Contains("\"amount\":\"222.00\"", response) + Assert.Contains("第一笔", response) diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index 6a9fc66..46516d5 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -539,3 +539,24 @@ module RedemptionPolicyTests = Assert.Equal(Error "redemption fee exceeds redemption value", RedemptionPolicy.compute 1.00000000m 2.5m 3.00m 100.00m 40.00000000m) Assert.Equal(Error "redemption proceeds rounds to zero", RedemptionPolicy.compute 0.00000001m 1500000m 0.01m 100.00m 40.00000000m) Assert.Equal(Error "redemption cash rounds to zero", RedemptionPolicy.compute 0.00000001m 2m 0.00m 100.00m 40.00000000m) + +module CapitalPolicyTests = + + open Xunit + open FundLab.Domain + + [] + let ``deposit validation rejects non positive or imprecise amounts`` () = + Assert.Equal(Error "deposit amount must be positive", CapitalPolicy.validateDeposit 0m) + Assert.Equal(Error "deposit amount must be positive", CapitalPolicy.validateDeposit -100.00m) + Assert.Equal(Error "deposit amount exceeds cash precision", CapitalPolicy.validateDeposit 100.005m) + Assert.Equal(Ok(), CapitalPolicy.validateDeposit 500.00m) + + [] + let ``settlement adds to available cash without touching units or nav`` () = + Assert.Equal(Ok 5398.51m, CapitalPolicy.settle 4898.51m 500.00m) + Assert.Equal(Ok 10000.00m, CapitalPolicy.settle 9000.00m 1000.00m) + + let overflow = CapitalPolicy.settle CapitalPolicy.cashMaximum 0.01m + + Assert.Equal(Error "available cash exceeds database precision", overflow) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 015ed45..5226b65 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -635,3 +635,75 @@ module RedemptionBoundaryTests = Assert.Equal("0.00000000", decoded.positions[0].reservedUnits) Assert.Equal("5.00000000", decoded.positions[1].reservedUnits) + +module CapitalBoundaryTests = + + let sampleFund () : FundSummary = + { + id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + name = "追加资金测试" + currency = "CNY" + initialCash = "10000.00" + initialUnitNav = "1.00000000" + isSynthetic = false + availableCash = "10000.00" + reservedCash = "0.00" + status = "active" + } + + [] + let ``capital deposit request requires fund token and positive amount`` () = + let noFund, _ = update CapitalDepositRequested { init () with token = "qa-token" } + + Assert.Equal(0, noFund.capitalSeq) + Assert.False(noFund.capitalInFlight) + + let noToken, _ = + update CapitalDepositRequested { init () with createdFund = Some(sampleFund ()) } + + Assert.Equal(Some "请输入 API token", noToken.error) + Assert.Equal(0, noToken.capitalSeq) + + let invalidAmount, _ = + update CapitalDepositRequested + { init () with token = "qa-token"; createdFund = Some(sampleFund ()); capitalAmount = "0.00" } + + Assert.Equal(Some "追加金额必须是大于零的两位小数金额,例如 1000.00", invalidAmount.error) + Assert.Equal(0, invalidAmount.capitalSeq) + + [] + let ``valid deposit starts one attempt and completion refreshes the fund`` () = + let started, _ = + update CapitalDepositRequested + { init () with token = "qa-token"; createdFund = Some(sampleFund ()); capitalAmount = "500.00" } + + Assert.Equal(1, started.capitalSeq) + Assert.True(started.capitalInFlight) + Assert.Equal("500.00", started.lastCapitalAttempt.Value.amount) + Assert.Equal(32, started.lastCapitalAttempt.Value.idempotencyKey.Length) + + let completed, _ = update (CapitalDepositCompleted(1, "500.00")) started + + Assert.False(completed.capitalInFlight) + Assert.Equal(Some "追加成功 + 500.00(现金余额已刷新)", completed.capitalNote) + + [] + let ``stale capital completion is ignored and token change clears the entry`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFund ()) + capitalSeq = 4 + capitalInFlight = true + lastCapitalAttempt = Some { idempotencyKey = "k"; amount = "500.00" } } + + let stale, _ = update (CapitalDepositCompleted(3, "500.00")) model + + Assert.True(stale.capitalInFlight) + + let cleared, _ = update (TokenChanged "new-token") model + + Assert.Equal("", cleared.capitalAmount) + Assert.Equal(None, cleared.createdFund) + Assert.False(cleared.capitalInFlight) + Assert.Equal(None, cleared.lastCapitalAttempt) -- cgit v1.2.3