summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js157
-rw-r--r--src/FundLab.Api/App.fs103
-rw-r--r--src/FundLab.Api/Persistence.fs331
-rw-r--r--src/FundLab.Web/App.fs298
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css37
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs400
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs1
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"
}