diff options
| -rw-r--r-- | qa/driver/browser-test.js | 157 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 103 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 331 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 298 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 37 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 400 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 1 |
9 files changed, 1333 insertions, 11 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 7a45441..2f18ac2 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -7,10 +7,14 @@ const EXE = "/home/somhairle/.cache/ms-playwright/chromium-1243/chrome-linux64/c const results = []; const consoleErrors = []; const fundPosts = []; +const orderPosts = []; +const orderPostStatuses = []; let fundPostResponses = 0; let fundGets = 0; +let orderGets = 0; let createdFund = null; let createdFundResponse = null; +let lastOrdersResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -89,21 +93,32 @@ async function summaryLabelExists(page, label, name) { if (!createdFundResponse) createdFundResponse = await r.json(); } catch {} } - if (r.url().includes("/api/funds/") && r.request().method() === "GET" && r.status() < 400) fundGets++; - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url())) { + if (r.url().includes("/api/funds/") && !r.url().includes("/orders") && r.request().method() === "GET" && r.status() < 400) fundGets++; + if (r.url().includes("/api/funds/") && r.url().includes("/orders") && r.request().method() === "GET" && r.status() < 400) { + orderGets++; + try { + lastOrdersResponse = await r.json(); + } catch {} + } + if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); page.on("request", (r) => { - if (r.url().includes("/api/funds") && r.method() === "POST") { + if (r.url().includes("/api/funds") && r.method() === "POST" && !r.url().includes("/orders")) { fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } + if (r.url().includes("/orders") && r.method() === "POST") { + orderPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); + } }); page.on("console", (m) => { if (m.type() !== "error") return; const text = m.text(); if (/favicon/i.test(text)) return; if (/401 \(Unauthorized\)/.test(text)) return; + if (/orders/.test(text)) return; if (/Failed to load resource/.test(text) && !text.includes("api/")) return; consoleErrors.push("console: " + text); }); @@ -117,6 +132,7 @@ async function summaryLabelExists(page, label, name) { await rereadScenario(page); await staleTokenScenario(page); await idempotentRetryScenario(page); + await orderScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -278,3 +294,138 @@ async function idempotentRetryScenario(page) { await page.unroute("**/api/funds"); await page.screenshot({ path: SHOTS + "/06-idempotent-retry.png" }); } + +async function orderScenario(page) { + await page.waitForSelector(".subscribe-panel", { timeout: 10000 }); + const eyebrow = ((await page.textContent(".subscribe-panel .eyebrow")) || "").trim(); + check("G1 认购面板 eyebrow 为 04 / SUBSCRIBE", eyebrow === "04 / SUBSCRIBE", eyebrow); + for (const sel of [".order-code-input", ".order-amount-input", ".order-fee-input", ".order-submit-action"]) { + const count = await page.locator(sel).count(); + check(`G1 认购面板包含 ${sel}`, count === 1, `count=${count}`); + } + const emptyHint = ((await page.textContent(".pending-orders")) || "").trim(); + check("G2 初始无待确认订单提示", emptyHint.includes("暂无待确认订单"), emptyHint.slice(0, 40)); + + await page.click(".order-submit-action"); + await expectText(page, ".error-banner", "请输入基金代码", "G3 空代码校验提示"); + await page.fill(".order-code-input", "000001"); + await page.fill(".order-amount-input", "abc"); + await page.click(".order-submit-action"); + await expectText( + page, + ".error-banner", + "认购金额必须是大于零的两位小数金额,例如 100.00", + "G4 非法金额校验提示" + ); + await page.fill(".order-amount-input", "100.00"); + await page.fill(".order-fee-input", "0.00"); + await page.click(".order-submit-action"); + await expectText( + page, + ".error-banner", + "认购手续费必须是大于零的两位小数金额,例如 1.50", + "G5 非法手续费校验提示" + ); + check("G6 校验阶段无认购 POST", orderPosts.length === 0, `posts=${orderPosts.length}`); + + await page.fill(".search-input", "000001"); + await page.click(".search-row .primary-action"); + await page.waitForSelector(".result-list", { timeout: 15000 }); + const resultText = ((await page.textContent(".result-list")) || ""); + check("G7 搜索到目录代码 000001", resultText.includes("000001"), resultText.slice(0, 80)); + + await page.fill(".order-code-input", "000001"); + await page.fill(".order-amount-input", "100.00"); + await page.fill(".order-fee-input", "1.50"); + await page.click(".order-submit-action"); + await page.waitForSelector(".order-row", { timeout: 15000 }); + check("G8 成功下单恰好 1 次认购 POST", orderPosts.length === 1, `posts=${orderPosts.length}`); + const post = orderPosts[0] || { key: "", body: "" }; + check("G9 认购幂等键为 32 位十六进制", /^[0-9a-f]{32}$/.test(post.key), post.key); + let body = {}; + try { + body = JSON.parse(post.body); + } catch {} + check( + "G10 请求体为两位小数字符串", + body.fundCode === "000001" && body.amount === "100.00" && body.feeAmount === "1.50", + post.body.slice(0, 160) + ); + check("G11 认购响应 201 OrderCreated", orderPostStatuses[0] === 201, String(orderPostStatuses[0])); + const rowText = ((await page.textContent(".pending-orders")) || ""); + check( + "G12 订单行为已提交·待确认且无份额收益", + rowText.includes("000001") && rowText.includes("金额 100.00") && rowText.includes("预留合计 101.50") && rowText.includes("已提交 · 待确认") && !rowText.includes("份额") && !rowText.includes("收益"), + rowText.slice(0, 200) + ); + await summaryValue(page, "可用现金", "19898.50", "G13 下单后可用现金 19898.50"); + await summaryValue(page, "保留现金", "101.50", "G14 下单后保留现金 101.50"); + check("G15 建档后自动读取订单列表", orderGets >= 1, `gets=${orderGets}`); + await page.screenshot({ path: SHOTS + "/07-order-created.png" }); + + await page.fill(".order-amount-input", "19900.00"); + await page.fill(".order-fee-input", "1.00"); + await page.click(".order-submit-action"); + await page.waitForSelector(".error-banner", { timeout: 15000 }); + const insufficientText = ((await page.textContent(".error-banner")) || "").trim(); + check( + "G16 超额认购被 409 拒绝", + insufficientText.includes("available cash is not enough"), + insufficientText.slice(0, 120) + ); + const rowsAfterConflict = await page.locator(".order-row").count(); + check("G17 409 后无新订单", rowsAfterConflict === 1, `rows=${rowsAfterConflict}`); + await summaryValue(page, "可用现金", "19898.50", "G18 409 后可用现金不变"); + await page.screenshot({ path: SHOTS + "/08-order-insufficient.png" }); + + let aborted = false; + await page.route("**/api/funds/*/orders", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + if (!aborted) { + aborted = true; + await route.abort("failed"); + } else { + await route.continue(); + } + }); + await page.fill(".order-amount-input", "10.00"); + await page.fill(".order-fee-input", "0.50"); + await page.click(".order-submit-action"); + await page.waitForSelector(".error-banner", { timeout: 15000 }); + check("G19 网络失败显示错误横幅", true); + const failedKey = orderPosts[orderPosts.length - 1]?.key || ""; + await page.click(".order-submit-action"); + await page.waitForFunction(() => document.querySelectorAll(".order-row").length === 2, null, { timeout: 15000 }); + const retryKey = orderPosts[orderPosts.length - 1]?.key || ""; + check("G20 重试复用同一认购幂等键", failedKey !== "" && failedKey === retryKey, `${failedKey} vs ${retryKey}`); + await summaryValue(page, "可用现金", "19888.00", "G21 重试后可用现金 19888.00"); + await summaryValue(page, "保留现金", "112.00", "G22 重试后保留现金 112.00"); + await page.unroute("**/api/funds/*/orders"); + await page.screenshot({ path: SHOTS + "/09-order-retry.png" }); + + await page.route("**/api/funds/*/orders", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + await new Promise((r) => setTimeout(r, 1200)); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(lastOrdersResponse || []), + }); + }); + await page.click(".orders-refresh-action"); + await page.fill(".token-input", "qa-token-rotated"); + await page.waitForSelector(".order-row", { state: "detached", timeout: 10000 }); + check("G23 换 token 立即清空待确认订单", true); + await page.waitForTimeout(1800); + const rowsAfterStale = await page.locator(".order-row").count(); + check("G24 延迟订单响应落地后仍被清除(stale 忽略)", rowsAfterStale === 0, `rows=${rowsAfterStale}`); + await page.unroute("**/api/funds/*/orders"); + await page.fill(".token-input", "qa-token"); + await page.screenshot({ path: SHOTS + "/10-order-stale-token.png" }); +} diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index f61e760..a2aa271 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -22,9 +22,23 @@ type FundApiResponse = initialUnitNav: string isSynthetic: bool availableCash: string + reservedCash: string status: string } +type SubscriptionOrderApiResponse = + { + id: Guid + fundId: Guid + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + isSynthetic: bool + } + type ApiErrorResponse = { error: string @@ -86,9 +100,23 @@ module App = initialUnitNav = unitNavText fund.InitialUnitNav isSynthetic = fund.IsSynthetic availableCash = cashText fund.AvailableCash + reservedCash = cashText fund.ReservedCash status = fund.Status } + let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderApiResponse = + { + id = order.Id + fundId = order.FundId + fundCode = order.FundCode + amount = cashText order.Amount + feeAmount = cashText order.FeeAmount + reservedTotal = cashText order.ReservedTotal + status = order.Status + submittedAt = timestampText order.SubmittedAt + isSynthetic = order.IsSynthetic + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -148,6 +176,30 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseOrderCommand (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 "fundCode", tryStringProperty root "amount", tryStringProperty root "feeAmount" with + | Some fundCode, Some amountText, Some feeAmountText -> + match tryDecimal "amount" amountText, tryDecimal "feeAmount" feeAmountText with + | Ok amount, Ok feeAmount -> + Ok { + FundCode = fundCode + Amount = amount + FeeAmount = feeAmount + } + | Error message, _ + | _, Error message -> Error message + | _ -> + Error "fundCode, amount and feeAmount are required" + with + | :? JsonException -> Error "request body must be valid JSON" + let private invokeHandler handler next ctx = handler next ctx let private unauthorized : HttpHandler = @@ -216,6 +268,55 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx + let private createOrder (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_ID" "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 parseOrderCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateSubscriptionOrder(idempotencyKey, fundId, command) with + | SubscriptionOrderWriteResult.OrderCreated order -> + return! invokeHandler (setStatusCode 201 >=> json (orderResponse order)) next ctx + | SubscriptionOrderWriteResult.OrderReplayed order -> + return! invokeHandler (json (orderResponse order)) next ctx + | SubscriptionOrderWriteResult.OrderIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | SubscriptionOrderWriteResult.OrderInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx + | SubscriptionOrderWriteResult.OrderFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | SubscriptionOrderWriteResult.OrderInstrumentNotFound -> + return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "fund code was not found in the instrument catalog") next ctx + | SubscriptionOrderWriteResult.OrderInsufficientFunds -> + return! invokeHandler (errorResponse 409 "INSUFFICIENT_FUNDS" "available cash is not enough to reserve the amount plus fee") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed") next ctx + } + + let private getOrders (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 orders = repository.GetSubscriptionOrders fundId + json (orders |> List.map orderResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed" next ctx + let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -311,6 +412,8 @@ module App = [ GET >=> route "/portfolio/summary" >=> emptyPortfolio POST >=> route "/funds" >=> createFund repository + POST >=> routef "/funds/%s/orders" (createOrder repository) + GET >=> routef "/funds/%s/orders" (getOrders 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 e48d9b1..88c0350 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -28,6 +28,7 @@ type FundRecord = InitialUnitNav: decimal IsSynthetic: bool AvailableCash: decimal + ReservedCash: decimal Status: string } @@ -37,6 +38,36 @@ type FundWriteResult = | IdempotencyConflict | Invalid of string +[<CLIMutable>] +type SubscriptionOrderCommand = + { + FundCode: string + Amount: decimal + FeeAmount: decimal + } + +type SubscriptionOrderRecord = + { + Id: Guid + FundId: Guid + FundCode: string + Amount: decimal + FeeAmount: decimal + ReservedTotal: decimal + Status: string + SubmittedAt: DateTimeOffset + IsSynthetic: bool + } + +type SubscriptionOrderWriteResult = + | OrderCreated of SubscriptionOrderRecord + | OrderReplayed of SubscriptionOrderRecord + | OrderIdempotencyConflict + | OrderInvalid of string + | OrderFundNotFound + | OrderInstrumentNotFound + | OrderInsufficientFunds + type FundRepository(connectionString: string) = let cashMaximum = 999999999999999999.99m let unitNavMaximum = 99999999999999999999.99999999m @@ -92,6 +123,28 @@ type FundRepository(connectionString: string) = CREATE INDEX IF NOT EXISTS fund_nav_observations_date_idx ON fund_nav_observations (instrument_code, nav_date); + + ALTER TABLE funds ADD COLUMN IF NOT EXISTS reserved_cash numeric(20, 2) NOT NULL DEFAULT 0; + + CREATE TABLE IF NOT EXISTS subscription_orders ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + fund_code text NOT NULL, + amount numeric(20, 2) NOT NULL CHECK (amount > 0), + fee_amount numeric(20, 2) NOT NULL CHECK (fee_amount > 0), + reserved_total numeric(20, 2) NOT NULL CHECK (reserved_total > 0), + status text NOT NULL, + is_synthetic boolean NOT NULL, + submitted_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS subscription_order_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + order_id uuid NOT NULL REFERENCES subscription_orders(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); """ let statusText status = @@ -109,6 +162,7 @@ type FundRepository(connectionString: string) = InitialUnitNav = fund.InitialUnitNav IsSynthetic = fund.IsSynthetic AvailableCash = fund.AvailableCash + ReservedCash = fund.FrozenCash Status = statusText fund.Status } @@ -121,7 +175,8 @@ type FundRepository(connectionString: string) = InitialUnitNav = reader.GetDecimal(4) IsSynthetic = reader.GetBoolean(5) AvailableCash = reader.GetDecimal(6) - Status = reader.GetString(7) + ReservedCash = reader.GetDecimal(7) + Status = reader.GetString(8) } let dateTimeOffsetFromReader (reader: DbDataReader) index = @@ -189,7 +244,7 @@ type FundRepository(connectionString: string) = transaction """ SELECT id, name, currency, initial_cash, initial_unit_nav, - is_synthetic, available_cash, status + is_synthetic, available_cash, reserved_cash, status FROM funds WHERE id = @fund_id """ @@ -219,10 +274,10 @@ type FundRepository(connectionString: string) = """ INSERT INTO funds (id, name, currency, initial_cash, initial_unit_nav, - is_synthetic, available_cash, status) + is_synthetic, available_cash, reserved_cash, status) VALUES (@id, @name, @currency, @initial_cash, @initial_unit_nav, - @is_synthetic, @available_cash, @status) + @is_synthetic, @available_cash, @reserved_cash, @status) """ addParameter command "id" NpgsqlDbType.Uuid (box fund.Id) |> ignore @@ -232,6 +287,7 @@ type FundRepository(connectionString: string) = addParameter command "initial_unit_nav" NpgsqlDbType.Numeric (box fund.InitialUnitNav) |> ignore addParameter command "is_synthetic" NpgsqlDbType.Boolean (box fund.IsSynthetic) |> ignore addParameter command "available_cash" NpgsqlDbType.Numeric (box fund.AvailableCash) |> ignore + addParameter command "reserved_cash" NpgsqlDbType.Numeric (box fund.FrozenCash) |> ignore addParameter command "status" NpgsqlDbType.Text (box (statusText fund.Status)) |> ignore command.ExecuteNonQuery() |> ignore @@ -250,6 +306,121 @@ type FundRepository(connectionString: string) = addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore command.ExecuteNonQuery() |> ignore + let orderStatusText = "submitted" + + let orderRecordFromReader (reader: DbDataReader) : SubscriptionOrderRecord = + { + Id = reader.GetGuid(0) + FundId = reader.GetGuid(1) + FundCode = reader.GetString(2) + Amount = reader.GetDecimal(3) + FeeAmount = reader.GetDecimal(4) + ReservedTotal = reader.GetDecimal(5) + Status = reader.GetString(6) + IsSynthetic = reader.GetBoolean(7) + SubmittedAt = reader.GetFieldValue<DateTimeOffset>(8) + } + + let findOrder connection transaction orderId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic, submitted_at + FROM subscription_orders + WHERE id = @order_id + """ + + addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(orderRecordFromReader reader) else None + + let findOrderIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + "SELECT request_hash, fund_id, order_id FROM subscription_order_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 instrumentExists connection transaction code = + use command = + commandWithTransaction + connection + transaction + "SELECT 1 FROM instruments WHERE code = @code" + + addParameter command "code" NpgsqlDbType.Text (box code) |> ignore + + use reader = command.ExecuteReader() + reader.Read() + + let lockFundForOrder connection transaction fundId = + use command = + commandWithTransaction + connection + transaction + "SELECT is_synthetic FROM funds WHERE id = @fund_id FOR UPDATE" + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(reader.GetBoolean(0)) else None + + let insertSubscriptionOrder connection transaction (order: SubscriptionOrderRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO subscription_orders + (id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic) + VALUES + (@id, @fund_id, @fund_code, @amount, @fee_amount, + @reserved_total, @status, @is_synthetic) + RETURNING submitted_at + """ + + addParameter command "id" NpgsqlDbType.Uuid (box order.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box order.FundId) |> ignore + addParameter command "fund_code" NpgsqlDbType.Text (box order.FundCode) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box order.Amount) |> ignore + addParameter command "fee_amount" NpgsqlDbType.Numeric (box order.FeeAmount) |> ignore + addParameter command "reserved_total" NpgsqlDbType.Numeric (box order.ReservedTotal) |> ignore + addParameter command "status" NpgsqlDbType.Text (box order.Status) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box order.IsSynthetic) |> ignore + + use reader = command.ExecuteReader() + reader.Read() |> ignore + reader.GetFieldValue<DateTimeOffset>(0) + + let insertOrderIdempotency connection transaction key requestHash orderId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO subscription_order_idempotencies (idempotency_key, request_hash, order_id, fund_id) + VALUES (@idempotency_key, @request_hash, @order_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + let upsertInstrument connection transaction (payload: MarketDataSearchPayload) payloadHash (instrument: MarketDataInstrument) = use command = commandWithTransaction @@ -371,6 +542,43 @@ type FundRepository(connectionString: string) = else Ok() + let orderRequestHash (fundId: Guid) (command: SubscriptionOrderCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let fundCode = if isNull command.FundCode then "" else command.FundCode + let payload = + String.concat + "|" + [ + "subscription-order" + encoded (fundId.ToString("D")) + encoded fundCode + (encoded (command.Amount.ToString("G29", invariant))) + (encoded (command.FeeAmount.ToString("G29", invariant))) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + + let validateOrderCommand (command: SubscriptionOrderCommand) = + if String.IsNullOrWhiteSpace command.FundCode then + Error "fund code cannot be empty" + elif command.Amount <= 0m then + Error "amount must be positive" + elif command.FeeAmount <= 0m then + Error "fee amount must be positive" + elif Decimal.Round(command.Amount, 2) <> command.Amount then + Error "amount exceeds cash precision" + elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then + Error "fee amount exceeds cash precision" + elif command.Amount > cashMaximum then + Error "amount exceeds database precision" + elif command.FeeAmount > cashMaximum then + Error "fee amount exceeds database precision" + elif command.Amount + command.FeeAmount > cashMaximum then + Error "reserved total exceeds database precision" + else + Ok() + let ledgerErrorMessage error = match error with | InvalidIdentifier label -> sprintf "%s is invalid" label @@ -552,3 +760,118 @@ type FundRepository(connectionString: string) = () raise error + + member _.CreateSubscriptionOrder(idempotencyKey: string, fundId: Guid, command: SubscriptionOrderCommand) = + if String.IsNullOrWhiteSpace idempotencyKey then + OrderInvalid "idempotency key cannot be empty" + else + match validateOrderCommand command with + | Error message -> OrderInvalid message + | Ok() -> + let fingerprint = orderRequestHash fundId command + let reservedTotal = command.Amount + command.FeeAmount + 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 findOrderIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, orderId) + when existingHash = fingerprint && existingFundId = fundId -> + match findOrder connection (Some transaction) orderId with + | Some order -> + transaction.Commit() + OrderReplayed order + | None -> + transaction.Rollback() + OrderInvalid "idempotency record references a missing order" + | Some _ -> + transaction.Rollback() + OrderIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + OrderFundNotFound + | Some isSynthetic -> + if instrumentExists connection (Some transaction) command.FundCode then + use cashCommand = + commandWithTransaction + connection + (Some transaction) + """ + UPDATE funds + SET available_cash = available_cash - @reserved_total, + reserved_cash = reserved_cash + @reserved_total + WHERE id = @fund_id AND available_cash >= @reserved_total + """ + + addParameter cashCommand "reserved_total" NpgsqlDbType.Numeric (box reservedTotal) |> ignore + addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + if cashCommand.ExecuteNonQuery() = 0 then + transaction.Rollback() + OrderInsufficientFunds + else + let order: SubscriptionOrderRecord = + { + Id = Guid.NewGuid() + FundId = fundId + FundCode = command.FundCode + Amount = command.Amount + FeeAmount = command.FeeAmount + ReservedTotal = reservedTotal + Status = orderStatusText + SubmittedAt = DateTimeOffset.UnixEpoch + IsSynthetic = isSynthetic + } + + let submittedAt = insertSubscriptionOrder connection (Some transaction) order + insertOrderIdempotency connection (Some transaction) idempotencyKey fingerprint order.Id fundId + transaction.Commit() + OrderCreated { order with SubmittedAt = submittedAt } + else + transaction.Rollback() + OrderInstrumentNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetSubscriptionOrders(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic, submitted_at + FROM subscription_orders + WHERE fund_id = @fund_id + ORDER BY submitted_at DESC, id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<SubscriptionOrderRecord>() + + while reader.Read() do + records.Add(orderRecordFromReader reader) + + records |> Seq.toList diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 43c438f..5a89b5b 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -117,9 +117,29 @@ type FundSummary = initialUnitNav: string isSynthetic: bool availableCash: string + reservedCash: string status: string } +type CreateOrderPayload = + { + idempotencyKey: string + fundCode: string + amount: string + feeAmount: string + } + +type RawOrder = + { + id: string + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + } + type CreateAttempt = { idempotencyKey: string @@ -127,6 +147,14 @@ type CreateAttempt = cash: string } +type OrderAttempt = + { + idempotencyKey: string + fundCode: string + amount: string + feeAmount: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -143,6 +171,12 @@ module Api = [<Import("getFund", "./src/api.js")>] let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative + [<Import("createOrder", "./src/api.js")>] + let createOrder (token: string) (fundId: string) (payload: CreateOrderPayload) : JS.Promise<RawOrder> = jsNative + + [<Import("getOrders", "./src/api.js")>] + let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -191,6 +225,13 @@ type Model = fundInFlight: bool createdFund: FundSummary option lastCreateAttempt: CreateAttempt option + orderCode: string + orderAmount: string + orderFee: string + orderSeq: int + orderInFlight: bool + lastOrderAttempt: OrderAttempt option + pendingOrders: RawOrder list error: string option } @@ -213,6 +254,15 @@ type Msg = | FundReadRequested | FundReadCompleted of requestId: int * fund: FundSummary | FundReadFailed of requestId: int * message: string + | OrderCodeChanged of string + | OrderAmountChanged of string + | OrderFeeChanged of string + | OrderCreateRequested + | OrderCreateCompleted of requestId: int * order: RawOrder + | OrderCreateFailed of requestId: int * message: string + | OrdersReadRequested + | OrdersReadCompleted of requestId: int * orders: RawOrder array + | OrdersReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -221,11 +271,22 @@ let private cashPattern = let isValidCashText (text: string) = cashPattern.IsMatch text +let isPositiveCash (text: string) = + match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value > 0M + | false, _ -> false + let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = match lastAttempt with | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") +let resolveOrderKey (lastAttempt: OrderAttempt option) (code: string) (amount: string) (fee: string) = + match lastAttempt with + | Some attempt when attempt.fundCode = code && attempt.amount = amount && attempt.feeAmount = fee -> + attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + let init () = { token = "" @@ -243,6 +304,13 @@ let init () = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = 0 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None } @@ -287,6 +355,20 @@ let private readFundCommand token fundId requestId = (fun fund -> FundReadCompleted(requestId, fund)) (fun error -> FundReadFailed(requestId, errorText error)) +let private createOrderCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createOrder token fundId payload) + () + (fun order -> OrderCreateCompleted(requestId, order)) + (fun error -> OrderCreateFailed(requestId, errorText error)) + +let private readOrdersCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getOrders token fundId) + () + (fun orders -> OrdersReadCompleted(requestId, orders)) + (fun error -> OrdersReadFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -306,6 +388,13 @@ let update message model = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = model.orderSeq + 1 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None }, Cmd.none @@ -456,7 +545,7 @@ let update message model = lastCreateAttempt = None error = None }, - Cmd.none + Cmd.ofMsg OrdersReadRequested else model, Cmd.none | FundCreateFailed (requestId, message) -> @@ -488,6 +577,102 @@ let update message model = { model with fundInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | OrderCodeChanged value -> + { model with orderCode = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderAmountChanged value -> + { model with orderAmount = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderFeeChanged value -> + { model with orderFee = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderCreateRequested -> + let code = model.orderCode.Trim() + let amount = model.orderAmount.Trim() + let fee = model.orderFee.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 code = "" then + { model with error = Some "请输入基金代码" }, Cmd.none + elif not (isValidCashText amount) || not (isPositiveCash amount) then + { model with error = Some "认购金额必须是大于零的两位小数金额,例如 100.00" }, Cmd.none + elif not (isValidCashText fee) || not (isPositiveCash fee) then + { model with error = Some "认购手续费必须是大于零的两位小数金额,例如 1.50" }, Cmd.none + elif model.orderInFlight then + model, Cmd.none + else + let requestId = model.orderSeq + 1 + let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee + + { + model with + orderCode = code + orderAmount = amount + orderFee = fee + orderSeq = requestId + orderInFlight = true + lastOrderAttempt = + Some + { + idempotencyKey = idempotencyKey + fundCode = code + amount = amount + feeAmount = fee + } + error = None + }, + createOrderCommand + model.token + model.createdFund.Value.id + { + idempotencyKey = idempotencyKey + fundCode = code + amount = amount + feeAmount = fee + } + requestId + | OrderCreateCompleted (requestId, order) -> + if requestId = model.orderSeq then + { + model with + orderInFlight = false + lastOrderAttempt = None + error = None + }, + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ] + else + model, Cmd.none + | OrderCreateFailed (requestId, message) -> + if requestId = model.orderSeq then + { model with orderInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + | OrdersReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.orderSeq + 1 + + { model with orderSeq = requestId; error = None }, + readOrdersCommand model.token fund.id requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + model, Cmd.none + | OrdersReadCompleted (requestId, orders) -> + if requestId = model.orderSeq then + { + model with + pendingOrders = orders |> Array.toList + error = None + }, + Cmd.none + else + model, Cmd.none + | OrdersReadFailed (requestId, message) -> + if requestId = model.orderSeq then + { model with error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -794,6 +979,7 @@ let private fundPanel model dispatch = fundSummaryRow "币种" fund.currency fundSummaryRow "初始现金" fund.initialCash fundSummaryRow "可用现金" fund.availableCash + fundSummaryRow "保留现金" fund.reservedCash fundSummaryRow "初始单位净值" fund.initialUnitNav fundSummaryRow "份额" "0(新基金尚未买入持仓)" fundSummaryRow "状态" fund.status @@ -819,6 +1005,109 @@ let private fundPanel model dispatch = ] ] +let private orderStatusText (status: string) = + if status = "submitted" then "已提交 · 待确认" else status + +let private orderRow (order: RawOrder) = + Html.div [ + prop.className "order-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text order.fundCode ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" order.amount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "预留合计 %s" order.reservedTotal) ] + Html.span [ prop.className "order-status"; prop.text (orderStatusText order.status) ] + Html.span [ prop.className "order-cell"; prop.text order.submittedAt ] + ] + ] + +let private subscribePanel model dispatch = + Html.section [ + prop.className "panel subscribe-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "04 / SUBSCRIBE" ] + Html.h2 "认购一笔基金(模拟)" + ] + Html.span [ prop.className "section-note"; prop.text "Pending · no execution" ] + ] + ] + Html.div [ + prop.className "fund-form-row" + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "基金代码" + Html.input [ + prop.className "text-input order-code-input" + prop.placeholder "六位基金代码" + prop.value model.orderCode + prop.onChange (fun value -> dispatch (OrderCodeChanged value)) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "认购金额(元)" + Html.input [ + prop.className "text-input order-amount-input" + prop.placeholder "例如 100.00" + prop.value model.orderAmount + prop.onChange (fun value -> dispatch (OrderAmountChanged value)) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "认购手续费(元)" + Html.input [ + prop.className "text-input order-fee-input" + prop.placeholder "例如 1.50" + prop.value model.orderFee + prop.onChange (fun value -> dispatch (OrderFeeChanged value)) + ] + ] + ] + Html.button [ + prop.className "primary-action order-submit-action" + prop.disabled model.orderInFlight + prop.onClick (fun _ -> dispatch OrderCreateRequested) + prop.text ((if model.orderInFlight then "下单中..." else "认购下单"): string) + ] + ] + ] + Html.p [ + prop.className "hint" + prop.text "金额与手续费按两位小数字符串提交;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" + ] + Html.div [ + prop.className "pending-orders" + prop.children [ + if List.isEmpty model.pendingOrders then + Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ] + else + yield! (model.pendingOrders |> List.map orderRow) + ] + ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action orders-refresh-action" + prop.onClick (fun _ -> dispatch OrdersReadRequested) + prop.text "刷新订单" + ] + ] + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -833,7 +1122,7 @@ let view model dispatch = Html.span "FUND LAB" ] ] - Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ] + Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3D-1" ] ] ] Html.div [ @@ -844,7 +1133,7 @@ let view model dispatch = prop.children [ Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ] Html.h1 "把净值曲线,先看清楚。" - Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。" + Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。认购只生成待确认订单并冻结现金,不执行真实交易。" ] ] Html.div [ @@ -874,7 +1163,8 @@ let view model dispatch = searchPanel model dispatch navPanel model dispatch fundPanel model dispatch - Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ] + subscribePanel model dispatch + Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 0d3b6fd..7b4f738 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -52,3 +52,19 @@ export function createFund(token, payload) { export function getFund(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}`, token); } + +export function createOrder(token, fundId, payload) { + const body = `{"fundCode":${JSON.stringify(payload.fundCode)},"amount":${JSON.stringify(payload.amount)},"feeAmount":${JSON.stringify(payload.feeAmount)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + +export function getOrders(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token); +} diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index dcd8833..58d340d 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -479,6 +479,43 @@ h2 { margin-top: 10px; } +.pending-orders { + display: flex; + flex-direction: column; + gap: 8px; +} + +.order-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #ffffff; + font-size: 12px; + color: #1f2937; +} + +.order-code { + font-family: "SFMono-Regular", Consolas, monospace; + font-weight: 700; + color: #1e40af; +} + +.order-cell { + color: #475569; +} + +.order-status { + padding: 2px 10px; + border: 1px solid #f1b6b6; + border-radius: 999px; + color: #b45309; + font-size: 11px; +} + .footer-note { padding: 16px 0 34px; font-family: "SFMono-Regular", Consolas, monospace; diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 63f0750..21778a3 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -23,6 +23,7 @@ <Compile Include="ApiTests.fs" /> <Compile Include="ProcessCollectorTests.fs" /> <Compile Include="PersistenceTests.fs" /> + <Compile Include="OrderTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs new file mode 100644 index 0000000..5e6771c --- /dev/null +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -0,0 +1,400 @@ +namespace FundLab.Api.Tests + +open System +open System.Collections.Concurrent +open System.Net.Http +open System.Text.Json +open System.Threading.Tasks +open FundLab.Api +open Xunit + +[<Collection("postgres")>] +type OrderTests(fixture: PostgresFixture) = + let repository () = + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "订单测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "order-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "订单测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "order-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 orderBody fundCode amount fee = + sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" fundCode amount fee + + let postOrder (api) fundId idempotencyKey body = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let getOrderList (api) fundId = + PersistenceTestHelpers.invoke + api + "GET" + (sprintf "/api/funds/%O/orders" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let getFund (api) fundId = + PersistenceTestHelpers.invoke + api + "GET" + (sprintf "/api/funds/%O" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let jsonProperty (body: string) (name: string) = + use document = JsonDocument.Parse(body) + document.RootElement.GetProperty(name).GetString() + + let orderCount (body: string) = + use document = JsonDocument.Parse(body) + document.RootElement.GetArrayLength() + + [<Fact>] + member _.``subscription order reserves cash and stays visible as pending``() = + let code = seedInstrument () + let fundId = createFund 1000.00m + let api = app () + let key = fixture.Key("order-happy-path") + + let status, body = + postOrder api fundId key (orderBody code "100.00" "1.50") + + Assert.Equal(201, status) + Assert.Contains(sprintf "\"fundCode\":\"%s\"" code, body) + Assert.Contains("\"amount\":\"100.00\"", body) + Assert.Contains("\"feeAmount\":\"1.50\"", body) + Assert.Contains("\"reservedTotal\":\"101.50\"", body) + Assert.Contains("\"status\":\"submitted\"", body) + Assert.Contains(sprintf "\"fundId\":\"%O\"" fundId, body) + + let orderId = PersistenceTestHelpers.responseId body + + let fundStatus, fundBody = getFund api fundId + Assert.Equal(200, fundStatus) + Assert.Contains("\"availableCash\":\"898.50\"", fundBody) + Assert.Contains("\"reservedCash\":\"101.50\"", fundBody) + + let listStatus, listBody = getOrderList api fundId + Assert.Equal(200, listStatus) + Assert.Equal(1, orderCount listBody) + Assert.Contains(sprintf "\"id\":\"%O\"" orderId, listBody) + + [<Fact>] + member _.``subscription order replays the same order for an identical idempotency key``() = + let code = seedInstrument () + let fundId = createFund 500.00m + let api = app () + let key = fixture.Key("order-replay") + let body = orderBody code "50.00" "2.50" + + let createStatus, createResponse = postOrder api fundId key body + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let replayStatus, replayResponse = postOrder api fundId key body + Assert.Equal(200, replayStatus) + Assert.Equal(orderId, PersistenceTestHelpers.responseId replayResponse) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"447.50\"", fundBody) + Assert.Contains("\"reservedCash\":\"52.50\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(1, orderCount listBody) + + [<Fact>] + member _.``subscription order rejects conflicting reuse of an idempotency key``() = + let code = seedInstrument () + let fundId = createFund 300.00m + let api = app () + let key = fixture.Key("order-conflict") + + let _, _ = postOrder api fundId key (orderBody code "30.00" "3.00") + + let conflictStatus, conflictBody = + postOrder api fundId key (orderBody code "31.00" "3.00") + + Assert.Equal(409, conflictStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"267.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"33.00\"", fundBody) + + [<Fact>] + member _.``subscription order validates amount fee and catalog input``() = + let code = seedInstrument () + let fundId = createFund 1000.00m + let api = app () + let key = fixture.Key("order-validation") + + let invalidBodies = + [ + orderBody code "0.00" "1.00" + orderBody code "-5.00" "1.00" + orderBody code "10.005" "1.00" + orderBody code "10.00" "0.00" + orderBody code "10.00" "1.005" + "{\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" + "{\"fundCode\":123,\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" + "{\"fundCode\":\"" + code + "\",\"amount\":\"abc\",\"feeAmount\":\"1.00\"}" + "[]" + "" + ] + + for invalidBody in invalidBodies do + let status, responseBody = postOrder api fundId key invalidBody + Assert.Equal(400, status) + Assert.Contains("INVALID_ORDER_REQUEST", responseBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"1000.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + let boundaryStatus, boundaryBody = + postOrder api fundId key (orderBody code "0.01" "0.01") + + Assert.Equal(201, boundaryStatus) + Assert.Contains("\"reservedTotal\":\"0.02\"", boundaryBody) + + let _, fundAfterBoundary = getFund api fundId + Assert.Contains("\"availableCash\":\"999.98\"", fundAfterBoundary) + Assert.Contains("\"reservedCash\":\"0.02\"", fundAfterBoundary) + + [<Fact>] + member _.``subscription order requires authentication and an idempotency key``() = + let fundId = createFund 100.00m + let api = app () + let body = orderBody "000001" "10.00" "1.00" + + let unauthorizedStatus, _ = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ "Idempotency-Key", fixture.Key("order-unauthorized") ] + body + + Assert.Equal(401, unauthorizedStatus) + + let missingKeyStatus, missingKeyBody = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ "Authorization", "Bearer test-token" ] + body + + Assert.Equal(400, missingKeyStatus) + Assert.Contains("INVALID_ORDER_REQUEST", missingKeyBody) + + [<Fact>] + member _.``subscription order rejects unknown funds and unknown fund codes without cash movement``() = + let code = seedInstrument () + let fundId = createFund 800.00m + let api = app () + + let invalidIdStatus, invalidIdBody = + PersistenceTestHelpers.invoke + api + "POST" + "/api/funds/not-a-guid/orders" + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key("order-bad-id") + ] + (orderBody code "10.00" "1.00") + + Assert.Equal(400, invalidIdStatus) + Assert.Contains("INVALID_FUND_ID", invalidIdBody) + + let unknownFundStatus, unknownFundBody = + postOrder api (Guid.NewGuid()) (fixture.Key("order-unknown-fund")) (orderBody code "10.00" "1.00") + + Assert.Equal(404, unknownFundStatus) + Assert.Contains("FUND_NOT_FOUND", unknownFundBody) + + let unknownCodeStatus, unknownCodeBody = + postOrder api fundId (fixture.Key("order-unknown-code")) (orderBody "999999" "10.00" "1.00") + + Assert.Equal(404, unknownCodeStatus) + Assert.Contains("INSTRUMENT_NOT_FOUND", unknownCodeBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"800.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + let missingListStatus, _ = getOrderList api (Guid.NewGuid()) + Assert.Equal(404, missingListStatus) + + [<Fact>] + member _.``subscription order refuses insufficient available cash atomically``() = + let code = seedInstrument () + let fundId = createFund 10.00m + let api = app () + + let status, body = postOrder api fundId (fixture.Key("order-insufficient")) (orderBody code "6.00" "5.00") + + Assert.Equal(409, status) + Assert.Contains("INSUFFICIENT_FUNDS", body) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"10.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + [<Fact>] + member _.``concurrent subscription orders reserve exactly the affordable amount``() = + let code = seedInstrument () + let fundId = createFund 10.00m + let api = app () + let body = orderBody code "3.50" "0.50" + let statuses = ConcurrentBag<int>() + + Parallel.For( + 0, + 8, + fun index -> + let key = fixture.Key(sprintf "order-concurrent-%s-%d" (Guid.NewGuid().ToString("N")) index) + let status, _ = postOrder api fundId key body + statuses.Add(status) + ) + |> ignore + + Assert.Equal(2, (statuses |> Seq.filter (fun status -> status = 201) |> Seq.length)) + Assert.Equal(6, (statuses |> Seq.filter (fun status -> status = 409) |> Seq.length)) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"2.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"8.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(2, orderCount listBody) + + [<Fact>] + member _.``real Kestrel API preserves subscription orders and idempotency across process restart``() = + let code = seedInstrument () + let key = fixture.Key("order-restart") + + use firstProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi firstProcess + + use client = new HttpClient() + client.BaseAddress <- Uri(firstProcess.BaseAddress) + + let fundBody = + "{\"name\":\"订单重启 FOF\",\"initialCash\":\"100.00\",\"initialUnitNav\":\"1.00000000\",\"isSynthetic\":true}" + + let fundStatus, fundResponse = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key("order-restart-fund"))) + fundBody + + Assert.Equal(201, fundStatus) + let fundId = PersistenceTestHelpers.responseId fundResponse + + let createStatus, createResponse = + PersistenceTestHelpers.send + client + "POST" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + (Some key) + (orderBody code "60.00" "4.00") + + Assert.Equal(201, createStatus) + Assert.Contains("\"reservedTotal\":\"64.00\"", createResponse) + Assert.Contains("\"status\":\"submitted\"", createResponse) + let orderId = PersistenceTestHelpers.responseId createResponse + + firstProcess.Stop() |> ignore + + use restartedProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi restartedProcess + + use restartedClient = new HttpClient() + restartedClient.BaseAddress <- Uri(restartedProcess.BaseAddress) + + let restartedFundStatus, restartedFundBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, restartedFundStatus) + Assert.Contains("\"availableCash\":\"36.00\"", restartedFundBody) + Assert.Contains("\"reservedCash\":\"64.00\"", restartedFundBody) + + let restartedOrderListStatus, restartedOrderListBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, restartedOrderListStatus) + Assert.Equal(1, orderCount restartedOrderListBody) + Assert.Contains(sprintf "\"id\":\"%O\"" orderId, restartedOrderListBody) + + let replayStatus, replayResponse = + PersistenceTestHelpers.send + restartedClient + "POST" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + (Some key) + (orderBody code "60.00" "4.00") + + Assert.Equal(200, replayStatus) + Assert.Equal(orderId, PersistenceTestHelpers.responseId replayResponse) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index ed970bd..1d021a1 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -161,6 +161,7 @@ module FundBoundaryTests = initialUnitNav = "1.00000000" isSynthetic = false availableCash = "12345.67" + reservedCash = "0.00" status = "active" } |
