summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js124
-rw-r--r--src/FundLab.Api/App.fs143
-rw-r--r--src/FundLab.Api/Persistence.fs689
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Domain/Redemption.fs84
-rw-r--r--src/FundLab.Web/App.fs449
-rw-r--r--src/FundLab.Web/src/api.js31
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs333
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs48
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs76
10 files changed, 1970 insertions, 8 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index 46a8f85..677ae54 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -11,6 +11,8 @@ const orderPosts = [];
const orderPostStatuses = [];
const confirmPosts = [];
const confirmPostStatuses = [];
+const redemptionPosts = [];
+const redemptionConfirmResponses = [];
let positionGets = 0;
let lastPositionsResponse = null;
let fundPostResponses = 0;
@@ -20,6 +22,7 @@ let createdFund = null;
let createdFundResponse = null;
let lastOrdersResponse = null;
let lastConfirmResponse = null;
+let lastRedemptionConfirmResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -106,7 +109,12 @@ async function summaryLabelExists(page, label, name) {
} catch {}
}
if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status());
- if (r.url().includes("/confirm") && r.request().method() === "POST") {
+ if (r.url().includes("/confirm") && r.url().includes("/redemptions") && r.request().method() === "POST") {
+ try {
+ lastRedemptionConfirmResponse = await r.json();
+ redemptionConfirmResponses.push(r.status());
+ } catch {}
+ } else if (r.url().includes("/confirm") && r.request().method() === "POST") {
confirmPostStatuses.push(r.status());
try {
lastConfirmResponse = await r.json();
@@ -118,7 +126,7 @@ async function summaryLabelExists(page, label, name) {
lastPositionsResponse = await r.json();
} catch {}
}
- if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url())) {
+ if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
});
@@ -132,6 +140,9 @@ async function summaryLabelExists(page, label, name) {
if (r.url().includes("/confirm") && r.method() === "POST") {
confirmPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "" });
}
+ if (r.url().includes("/redemptions") && r.method() === "POST" && !r.url().includes("/confirm")) {
+ redemptionPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" });
+ }
});
page.on("console", (m) => {
if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text());
@@ -155,11 +166,120 @@ async function summaryLabelExists(page, label, name) {
await idempotentRetryScenario(page);
await orderScenario(page);
await confirmScenario(page);
+ await redeemScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
}
+
+async function redeemScenario(page) {
+ // K 系列: 赎回提交(冻结份额) -> 确认(现金到账+份额核销),接 J 系列持仓 84.01949252
+ await page.waitForSelector(".redeem-panel", { timeout: 10000 });
+ const redeemEyebrow = ((await page.textContent(".redeem-panel .eyebrow")) || "").trim();
+ check("K1 赎回面板 eyebrow 为 06 / REDEEM", redeemEyebrow === "06 / REDEEM", redeemEyebrow);
+
+ await page.fill(".redemption-code-input", "000001");
+ await page.fill(".redemption-units-input", "34.01949252");
+ await page.fill(".redemption-fee-input", "1.00");
+ await page.click(".redemption-submit-action");
+ await page.waitForSelector(".pending-redemptions .order-row", { timeout: 15000 });
+ const submittedRedemption = ((await page.textContent(".pending-redemptions")) || "");
+ check(
+ "K2 提交赎回后显示已提交·待确认",
+ submittedRedemption.includes("已提交 · 待确认") && submittedRedemption.includes("份额 34.01949252"),
+ submittedRedemption.slice(0, 200)
+ );
+
+ // 提交后: 份额冻结但总份额与现金不变
+ await page.waitForFunction(
+ () => {
+ const panel = document.querySelector(".positions-panel");
+ return panel !== null && panel.textContent.includes("冻结份额 34.01949252");
+ },
+ null,
+ { timeout: 15000 }
+ );
+ const frozenCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim()));
+ check(
+ "K3 提交后冻结份额且现金不变",
+ frozenCash.includes("4898.51") && frozenCash.includes("0.00"),
+ JSON.stringify(frozenCash)
+ );
+
+ await page.click(".redemption-confirm-action");
+ for (let i = 0; i < 200 && (redemptionConfirmResponses.length === 0 || lastRedemptionConfirmResponse === null); i++) {
+ await page.waitForTimeout(100);
+ }
+ const redemptionStatus = lastRedemptionConfirmResponse ? lastRedemptionConfirmResponse.status : "";
+ check("K4 赎回确认响应为 confirmed", redemptionStatus === "confirmed", redemptionStatus);
+ check(
+ "K5 赎回确认展示 NAV 与到账金额精确字符串",
+ lastRedemptionConfirmResponse.confirmedNav === "1.19020000" &&
+ /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedProceeds || "") &&
+ /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedCostReleased || ""),
+ JSON.stringify(lastRedemptionConfirmResponse).slice(0, 220)
+ );
+
+ await page.waitForFunction(
+ () => {
+ const panel = document.querySelector(".positions-panel");
+ return panel !== null && panel.textContent.includes("份额 50.00000000");
+ },
+ null,
+ { timeout: 15000 }
+ );
+ const proceeds = parseFloat(lastRedemptionConfirmResponse.confirmedProceeds);
+ const costReleased = parseFloat(lastRedemptionConfirmResponse.confirmedCostReleased);
+ const expectedAvailable = (4898.51 + proceeds).toFixed(2);
+ const expectedCost = (99.99 - costReleased).toFixed(2);
+ await page.waitForFunction(
+ (want) => {
+ const values = Array.from(document.querySelectorAll(".positions-panel .fund-detail-value")).map((el) => el.textContent.trim());
+ return values.includes(want.available);
+ },
+ { available: expectedAvailable },
+ { timeout: 15000 }
+ );
+ const settledCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim()));
+ const positionText = ((await page.textContent(".positions-panel")) || "");
+ check(
+ "K6 确认后份额核销且现金按应收入账",
+ positionText.includes("份额 50.00000000") &&
+ !positionText.includes("冻结份额") &&
+ positionText.includes(expectedCost) &&
+ settledCash.includes(expectedAvailable) &&
+ settledCash.includes("0.00"),
+ JSON.stringify({ positionText: positionText.slice(0, 220), settledCash, expectedAvailable, expectedCost })
+ );
+
+ const redemptionRowText = ((await page.textContent(".pending-redemptions")) || "");
+ check(
+ "K7 赎回订单行显示已确认与到账金额",
+ redemptionRowText.includes("已确认") && redemptionRowText.includes(`赎回到账 ${lastRedemptionConfirmResponse.confirmedProceeds}`),
+ redemptionRowText.slice(0, 240)
+ );
+
+ // 份额上限校验: 超额赎回 409 且不产生冻结
+ await page.fill(".redemption-units-input", "999999999.00000000");
+ await page.fill(".redemption-fee-input", "0.00");
+ await page.click(".redemption-submit-action");
+ await page.waitForSelector(".error-banner", { timeout: 15000 });
+ const insufficientText = ((await page.textContent(".error-banner")) || "").trim();
+ check(
+ "K8 超额赎回被 409 拒绝",
+ insufficientText.includes("available holdings are not enough"),
+ insufficientText.slice(0, 140)
+ );
+ const frozenAfterConflict = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim()));
+ check(
+ "K9 超额赎回后现金与冻结不变",
+ frozenAfterConflict.includes(expectedAvailable) && !((await page.textContent(".positions-panel")) || "").includes("冻结份额"),
+ JSON.stringify(frozenAfterConflict)
+ );
+ await page.screenshot({ path: SHOTS + "/13-redemption-settled.png" });
+}
+
const failed = results.filter((r) => !r.ok);
console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`);
process.exit(failed.length > 0 ? 1 : 0);
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 97787aa..9712fc6 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -96,6 +96,7 @@ type FundPositionResponse =
{
instrumentCode: string
units: string
+ reservedUnits: string
costCash: string
lastConfirmedAt: string
valuationNav: string option
@@ -103,6 +104,25 @@ type FundPositionResponse =
valuationCollectedAt: string option
}
+type RedemptionOrderResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ units: string
+ feeAmount: string
+ status: string
+ submittedAt: string
+ tradeDate: string
+ pendingReason: string option
+ confirmedAt: string option
+ confirmedNav: string option
+ confirmedNavDate: string option
+ confirmedProceeds: string option
+ confirmedCostReleased: string option
+ isSynthetic: bool
+ }
+
type FundPositionsResponse =
{
fundId: Guid
@@ -222,6 +242,25 @@ module App =
isSynthetic = order.IsSynthetic
}
+ let private redemptionResponse (order: RedemptionOrderRecord) : RedemptionOrderResponse =
+ {
+ id = order.Id
+ fundId = order.FundId
+ instrumentCode = order.InstrumentCode
+ units = decimalText order.Units
+ feeAmount = cashText order.FeeAmount
+ status = order.Status
+ submittedAt = timestampText order.SubmittedAt
+ tradeDate = dateText order.TradeDate
+ pendingReason = order.PendingReason
+ confirmedAt = order.ConfirmedAt |> Option.map timestampText
+ confirmedNav = order.ConfirmedNav |> Option.map decimalText
+ confirmedNavDate = order.ConfirmedNavDate |> Option.map dateText
+ confirmedProceeds = order.ConfirmedProceeds |> Option.map cashText
+ confirmedCostReleased = order.ConfirmedCostReleased |> Option.map cashText
+ isSynthetic = order.IsSynthetic
+ }
+
let private errorResponse status error message : HttpHandler =
setStatusCode status
>=> json ({
@@ -305,6 +344,31 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
+ let private parseRedemptionCommand (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 "instrumentCode", tryStringProperty root "units", tryStringProperty root "feeAmount" with
+ | Some code, Some unitsText, Some feeAmountText ->
+ match tryDecimal "units" unitsText, tryDecimal "feeAmount" feeAmountText with
+ | Ok units, Ok feeAmount ->
+ Ok
+ {
+ InstrumentCode = code
+ Units = units
+ FeeAmount = feeAmount
+ }
+ | Error message, _
+ | _, Error message -> Error message
+ | _ ->
+ Error "instrumentCode, units 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 =
@@ -462,6 +526,7 @@ module App =
{
instrumentCode = position.InstrumentCode
units = decimalText position.Units
+ reservedUnits = decimalText position.ReservedUnits
costCash = cashText position.CostCash
lastConfirmedAt = timestampText position.LastConfirmedAt
valuationNav = position.ValuationNav |> Option.map decimalText
@@ -481,6 +546,81 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "position persistence failed" next ctx
+ let private createRedemption (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_REDEMPTION_REQUEST" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseRedemptionCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_REDEMPTION_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateRedemptionOrder(idempotencyKey, fundId, command) with
+ | RedemptionWriteResult.RedemptionCreated order ->
+ return! invokeHandler (setStatusCode 201 >=> json (redemptionResponse order)) next ctx
+ | RedemptionWriteResult.RedemptionReplayed order ->
+ return! invokeHandler (json (redemptionResponse order)) next ctx
+ | RedemptionWriteResult.RedemptionIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | RedemptionWriteResult.RedemptionInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_REDEMPTION_REQUEST" message) next ctx
+ | RedemptionWriteResult.RedemptionFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | RedemptionWriteResult.RedemptionInstrumentNotFound ->
+ return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "instrument code was not found in the instrument catalog") next ctx
+ | RedemptionWriteResult.RedemptionInsufficientUnits ->
+ return! invokeHandler (errorResponse 409 "INSUFFICIENT_UNITS" "available holdings are not enough for the requested redemption units") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "redemption persistence failed") next ctx
+ }
+
+ let private getRedemptions (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.GetRedemptionOrders fundId
+ json (orders |> List.map redemptionResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "redemption persistence failed" next ctx
+
+ let private confirmRedemption (repository: FundRepository) (fundIdText: string) (orderIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText, Guid.TryParse orderIdText with
+ | (false, _), _
+ | _, (false, _) ->
+ errorResponse 400 "INVALID_CONFIRM_REQUEST" "fund id and order id must be UUIDs" next ctx
+ | (true, fundId), (true, orderId) ->
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ try
+ match repository.ConfirmRedemptionOrder(idempotencyKey, fundId, orderId) with
+ | RedemptionConfirmed order
+ | RedemptionConfirmReplayed order
+ | RedemptionPendingNav order -> json (redemptionResponse order) next ctx
+ | RedemptionConfirmIdempotencyConflict ->
+ errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request" next ctx
+ | RedemptionAlreadyConfirmed ->
+ errorResponse 409 "ORDER_ALREADY_CONFIRMED" "order was already confirmed with a different idempotency key" next ctx
+ | RedemptionOrderNotFound -> errorResponse 404 "ORDER_NOT_FOUND" "order was not found" next ctx
+ | RedemptionInvalidStatus ->
+ errorResponse 409 "ORDER_INVALID_STATUS" "order is not in a confirmable status" next ctx
+ | RedemptionConfirmResult.RedemptionInvalid message ->
+ errorResponse 400 "INVALID_CONFIRM_REQUEST" message next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "redemption confirmation failed" next ctx
+
let private marketDataError (failure: MarketDataFailure) : HttpHandler =
let status, error, message =
match failure with
@@ -579,6 +719,9 @@ module App =
POST >=> routef "/funds/%s/orders" (createOrder repository)
GET >=> routef "/funds/%s/orders" (getOrders repository)
POST >=> routef "/funds/%s/orders/%s/confirm" (fun (fundId, orderId) -> confirmOrder repository fundId orderId)
+ POST >=> routef "/funds/%s/redemptions" (createRedemption repository)
+ GET >=> routef "/funds/%s/redemptions" (getRedemptions repository)
+ POST >=> routef "/funds/%s/redemptions/%s/confirm" (fun (fundId, orderId) -> confirmRedemption repository fundId orderId)
GET >=> routef "/funds/%s/positions" (getPositions repository)
GET >=> routef "/funds/%s" (getFund repository)
]
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 6080beb..85396d5 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -202,6 +202,7 @@ type FundPositionRecord =
FundId: Guid
InstrumentCode: string
Units: decimal
+ ReservedUnits: decimal
CostCash: decimal
LastConfirmedAt: DateTimeOffset
ValuationNav: decimal option
@@ -209,6 +210,52 @@ type FundPositionRecord =
ValuationCollectedAt: DateTimeOffset option
}
+type RedemptionCommand =
+ {
+ InstrumentCode: string
+ Units: decimal
+ FeeAmount: decimal
+ }
+
+type RedemptionOrderRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ InstrumentCode: string
+ Units: decimal
+ FeeAmount: decimal
+ Status: string
+ IsSynthetic: bool
+ SubmittedAt: DateTimeOffset
+ TradeDate: DateOnly
+ ConfirmIdempotencyKey: string option
+ PendingReason: string option
+ ConfirmedAt: DateTimeOffset option
+ ConfirmedNav: decimal option
+ ConfirmedNavDate: DateOnly option
+ ConfirmedProceeds: decimal option
+ ConfirmedCostReleased: decimal option
+ }
+
+type RedemptionWriteResult =
+ | RedemptionCreated of RedemptionOrderRecord
+ | RedemptionReplayed of RedemptionOrderRecord
+ | RedemptionIdempotencyConflict
+ | RedemptionInvalid of string
+ | RedemptionFundNotFound
+ | RedemptionInstrumentNotFound
+ | RedemptionInsufficientUnits
+
+type RedemptionConfirmResult =
+ | RedemptionConfirmed of RedemptionOrderRecord
+ | RedemptionConfirmReplayed of RedemptionOrderRecord
+ | RedemptionPendingNav of RedemptionOrderRecord
+ | RedemptionConfirmIdempotencyConflict
+ | RedemptionAlreadyConfirmed
+ | RedemptionOrderNotFound
+ | RedemptionInvalidStatus
+ | RedemptionInvalid of string
+
type FundRepository(connectionString: string) =
let cashMaximum = 999999999999999999.99m
let unitNavMaximum = 99999999999999999999.99999999m
@@ -349,6 +396,43 @@ type FundRepository(connectionString: string) =
fund_id uuid NOT NULL REFERENCES funds(id),
created_at timestamptz NOT NULL DEFAULT now()
);
+
+ CREATE TABLE IF NOT EXISTS redemption_orders (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ instrument_code text NOT NULL REFERENCES instruments(code),
+ units numeric(28, 8) NOT NULL CHECK (units > 0),
+ fee_amount numeric(20, 2) NOT NULL CHECK (fee_amount >= 0),
+ status text NOT NULL,
+ is_synthetic boolean NOT NULL,
+ submitted_at timestamptz NOT NULL DEFAULT now(),
+ trade_date date NOT NULL,
+ confirm_idempotency_key text NULL,
+ pending_reason text NULL,
+ confirmed_at timestamptz NULL,
+ confirmed_nav numeric(28, 8) NULL,
+ confirmed_nav_date date NULL,
+ confirmed_proceeds numeric(20, 2) NULL,
+ confirmed_cost_released numeric(20, 2) NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS redemption_order_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ order_id uuid NOT NULL REFERENCES redemption_orders(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE IF NOT EXISTS redemption_confirm_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ order_id uuid NOT NULL REFERENCES redemption_orders(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
+ ALTER TABLE fund_positions ADD COLUMN IF NOT EXISTS reserved_units numeric(28, 8) NOT NULL DEFAULT 0;
"""
let statusText status =
@@ -897,6 +981,173 @@ type FundRepository(connectionString: string) =
| FundAlreadyExists fundId -> sprintf "fund %O already exists" fundId
| other -> sprintf "%A" other
+ let redemptionRecordFromReader (reader: DbDataReader) : RedemptionOrderRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ InstrumentCode = reader.GetString(2)
+ Units = reader.GetDecimal(3)
+ FeeAmount = reader.GetDecimal(4)
+ Status = reader.GetString(5)
+ IsSynthetic = reader.GetBoolean(6)
+ SubmittedAt = reader.GetFieldValue<DateTimeOffset>(7)
+ TradeDate = reader.GetFieldValue<DateOnly>(8)
+ ConfirmIdempotencyKey = readStringOption reader 9
+ PendingReason = readStringOption reader 10
+ ConfirmedAt = optionalDateTimeOffsetFromReader reader 11
+ ConfirmedNav = readDecimalOption reader 12
+ ConfirmedNavDate =
+ if reader.IsDBNull(13) then
+ None
+ else
+ Some(reader.GetFieldValue<DateOnly>(13))
+ ConfirmedProceeds = readDecimalOption reader 14
+ ConfirmedCostReleased = readDecimalOption reader 15
+ }
+
+ let redemptionOrderColumns =
+ """
+ SELECT id, fund_id, instrument_code, units, fee_amount,
+ status, is_synthetic, submitted_at, trade_date,
+ confirm_idempotency_key, pending_reason, confirmed_at,
+ confirmed_nav, confirmed_nav_date, confirmed_proceeds, confirmed_cost_released
+ FROM redemption_orders
+ """
+
+ let findRedemptionOrder connection transaction orderId =
+ use command =
+ commandWithTransaction connection transaction (redemptionOrderColumns + " WHERE id = @order_id")
+
+ addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then Some(redemptionRecordFromReader reader) else None
+
+ let findRedemptionOrderIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT request_hash, fund_id, order_id FROM redemption_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 findRedemptionConfirmIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT request_hash, fund_id, order_id FROM redemption_confirm_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 insertRedemptionOrder connection transaction (order: RedemptionOrderRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO redemption_orders
+ (id, fund_id, instrument_code, units, fee_amount,
+ status, is_synthetic, trade_date)
+ VALUES
+ (@id, @fund_id, @instrument_code, @units, @fee_amount,
+ @status, @is_synthetic, @trade_date)
+ RETURNING submitted_at
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box order.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box order.FundId) |> ignore
+ addParameter command "instrument_code" NpgsqlDbType.Text (box order.InstrumentCode) |> ignore
+ addParameter command "units" NpgsqlDbType.Numeric (box order.Units) |> ignore
+ addParameter command "fee_amount" NpgsqlDbType.Numeric (box order.FeeAmount) |> ignore
+ addParameter command "status" NpgsqlDbType.Text (box order.Status) |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box order.IsSynthetic) |> ignore
+ addParameter command "trade_date" NpgsqlDbType.Date (box order.TradeDate) |> ignore
+
+ use reader = command.ExecuteReader()
+ reader.Read() |> ignore
+ reader.GetFieldValue<DateTimeOffset>(0)
+
+ let insertRedemptionOrderIdempotency connection transaction key requestHash orderId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO redemption_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 insertRedemptionConfirmIdempotency connection transaction key requestHash orderId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO redemption_confirm_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 redemptionRequestHash (fundId: Guid) (command: RedemptionCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let code = if isNull command.InstrumentCode then "" else command.InstrumentCode
+ let payload =
+ String.concat
+ "|"
+ [
+ "redemption-order"
+ encoded (fundId.ToString("D"))
+ encoded code
+ (encoded (command.Units.ToString("G29", invariant)))
+ (encoded (command.FeeAmount.ToString("G29", invariant)))
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
+ let validateRedemptionCommand (command: RedemptionCommand) =
+ if String.IsNullOrWhiteSpace command.InstrumentCode then
+ Error "instrument code cannot be empty"
+ elif command.Units <= 0m then
+ Error "units must be positive"
+ elif command.FeeAmount < 0m then
+ Error "fee amount cannot be negative"
+ elif Decimal.Round(command.Units, 8) <> command.Units then
+ Error "units exceed supported precision"
+ elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then
+ Error "fee amount exceeds cash precision"
+ elif command.Units > RedemptionPolicy.unitsMaximum then
+ Error "units exceed database precision"
+ elif command.FeeAmount > cashMaximum then
+ Error "fee amount exceeds database precision"
+ else
+ Ok()
+
member _.EnsureSchema() =
use connection = new NpgsqlConnection(connectionString)
connection.Open()
@@ -1200,6 +1451,431 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.CreateRedemptionOrder(idempotencyKey: string, fundId: Guid, command: RedemptionCommand) : RedemptionWriteResult =
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ RedemptionWriteResult.RedemptionInvalid "idempotency key cannot be empty"
+ else
+ match validateRedemptionCommand command with
+ | Error message -> RedemptionWriteResult.RedemptionInvalid message
+ | Ok() ->
+ let fingerprint = redemptionRequestHash fundId command
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findRedemptionOrderIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, orderId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findRedemptionOrder connection (Some transaction) orderId with
+ | Some order ->
+ transaction.Commit()
+ RedemptionReplayed order
+ | None ->
+ transaction.Rollback()
+ RedemptionWriteResult.RedemptionInvalid "idempotency record references a missing order"
+ | Some _ ->
+ transaction.Rollback()
+ RedemptionIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ RedemptionFundNotFound
+ | Some isSynthetic ->
+ if instrumentExists connection (Some transaction) command.InstrumentCode then
+ use freezeCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE fund_positions
+ SET reserved_units = reserved_units + @units
+ WHERE fund_id = @fund_id
+ AND instrument_code = @code
+ AND units - reserved_units >= @units
+ """
+
+ addParameter freezeCommand "units" NpgsqlDbType.Numeric (box command.Units) |> ignore
+ addParameter freezeCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter freezeCommand "code" NpgsqlDbType.Text (box command.InstrumentCode) |> ignore
+
+ if freezeCommand.ExecuteNonQuery() = 0 then
+ transaction.Rollback()
+ RedemptionInsufficientUnits
+ else
+ let submittedAt = DateTimeOffset.UtcNow
+ let order: RedemptionOrderRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ InstrumentCode = command.InstrumentCode
+ Units = command.Units
+ FeeAmount = command.FeeAmount
+ Status = "submitted"
+ IsSynthetic = isSynthetic
+ SubmittedAt = submittedAt
+ TradeDate = ConfirmationPolicy.tradeDateFor submittedAt
+ ConfirmIdempotencyKey = None
+ PendingReason = None
+ ConfirmedAt = None
+ ConfirmedNav = None
+ ConfirmedNavDate = None
+ ConfirmedProceeds = None
+ ConfirmedCostReleased = None
+ }
+
+ let submittedAt = insertRedemptionOrder connection (Some transaction) order
+ insertRedemptionOrderIdempotency connection (Some transaction) idempotencyKey fingerprint order.Id fundId
+ transaction.Commit()
+ RedemptionCreated { order with SubmittedAt = submittedAt }
+ else
+ transaction.Rollback()
+ RedemptionInstrumentNotFound
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetRedemptionOrders(fundId: Guid) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction connection None (redemptionOrderColumns + " 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<RedemptionOrderRecord>()
+
+ while reader.Read() do
+ records.Add(redemptionRecordFromReader reader)
+
+ records |> Seq.toList
+
+ member _.ConfirmRedemptionOrder(idempotencyKey: string, fundId: Guid, orderId: Guid) : RedemptionConfirmResult =
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ RedemptionConfirmResult.RedemptionInvalid "idempotency key cannot be empty"
+ else
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ let confirmedAt = DateTimeOffset.UtcNow
+ let today = ConfirmationPolicy.shanghaiDate confirmedAt
+
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "confirm-redemption:%O" orderId)) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findRedemptionOrder connection (Some transaction) orderId with
+ | None ->
+ transaction.Rollback()
+ RedemptionOrderNotFound
+ | Some order when order.FundId <> fundId ->
+ transaction.Rollback()
+ RedemptionOrderNotFound
+ | Some order ->
+ if order.Status = "confirmed" then
+ match order.ConfirmIdempotencyKey with
+ | Some storedKey when storedKey = idempotencyKey ->
+ transaction.Commit()
+ RedemptionConfirmReplayed order
+ | _ ->
+ match findRedemptionConfirmIdempotency connection (Some transaction) idempotencyKey with
+ | Some(_, _, storedOrderId) when storedOrderId = orderId ->
+ transaction.Commit()
+ RedemptionConfirmReplayed order
+ | Some _ ->
+ transaction.Rollback()
+ RedemptionConfirmIdempotencyConflict
+ | None ->
+ transaction.Rollback()
+ RedemptionAlreadyConfirmed
+ elif order.Status <> "submitted" && order.Status <> "pending_nav" then
+ transaction.Rollback()
+ RedemptionInvalidStatus
+ else
+ match findRedemptionConfirmIdempotency connection (Some transaction) idempotencyKey with
+ | Some _ ->
+ transaction.Rollback()
+ RedemptionConfirmIdempotencyConflict
+ | None ->
+ let tradeDate = order.TradeDate
+ let tradeDateText = tradeDate.ToString("yyyy-MM-dd")
+
+ use quoteCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ SELECT o.nav, o.nav_date, o.source, o.source_revision, o.source_collected_at, o.published_at,
+ o.source_payload_hash, o.first_seen_at, e.first_seen_at
+ FROM fund_nav_observations o
+ LEFT JOIN fund_nav_observation_evidence e
+ ON e.instrument_code = o.instrument_code
+ AND e.nav_date = o.nav_date
+ AND e.source_payload_hash = o.source_payload_hash
+ WHERE o.instrument_code = @code AND o.nav_date = @trade_date AND o.nav > 0
+ ORDER BY o.source_collected_at DESC, o.published_at DESC NULLS LAST, o.source_revision DESC
+ LIMIT 1
+ """
+
+ addParameter quoteCommand "code" NpgsqlDbType.Text (box order.InstrumentCode) |> ignore
+ addParameter quoteCommand "trade_date" NpgsqlDbType.Date (box tradeDate) |> ignore
+
+ use quoteReader = quoteCommand.ExecuteReader()
+ let quoteFound = quoteReader.Read()
+
+ let selectedQuote =
+ if quoteFound then
+ let evidenceFirstSeen =
+ if quoteReader.IsDBNull(8) then
+ None
+ else
+ Some(quoteReader.GetFieldValue<DateTimeOffset>(8))
+
+ Some
+ ({
+ Nav = quoteReader.GetDecimal(0)
+ NavDate = quoteReader.GetFieldValue<DateOnly>(1)
+ Source = quoteReader.GetString(2)
+ Revision = quoteReader.GetString(3)
+ CollectedAt = quoteReader.GetFieldValue<DateTimeOffset>(4)
+ PublishedAt =
+ if quoteReader.IsDBNull(5) then
+ None
+ else
+ Some(quoteReader.GetFieldValue<DateTimeOffset>(5))
+ PayloadHash = quoteReader.GetString(6)
+ FirstSeenAt = quoteReader.GetFieldValue<DateTimeOffset>(7)
+ },
+ evidenceFirstSeen)
+ else
+ None
+
+ quoteReader.Close()
+
+ let boundQuote =
+ match selectedQuote with
+ | Some(quote, Some firstSeen) -> Some { quote with FirstSeenAt = firstSeen }
+ | _ -> None
+
+ let deferralReason =
+ match selectedQuote with
+ | None ->
+ Some(sprintf "nav for trade date %s is not available yet" tradeDateText)
+ | Some(_, evidenceFirstSeen) when evidenceFirstSeen.IsNone ->
+ Some(sprintf "nav revision for trade date %s has no observation evidence recorded" tradeDateText)
+ | Some(quote, _) when quote.FirstSeenAt > confirmedAt ->
+ Some(sprintf "nav revision for trade date %s was first observed after the confirmation attempt" tradeDateText)
+ | Some(quote, _) ->
+ ConfirmationPolicy.navDeferralReason
+ {
+ Nav = quote.Nav
+ NavDate = quote.NavDate
+ CollectedAt = quote.CollectedAt
+ PublishedAt = quote.PublishedAt
+ }
+ tradeDate
+ today
+ confirmedAt
+
+ match deferralReason with
+ | Some reason ->
+ use pendingCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE redemption_orders
+ SET status = 'pending_nav',
+ pending_reason = @reason,
+ confirm_idempotency_key = NULL,
+ confirmed_at = NULL,
+ confirmed_nav = NULL,
+ confirmed_nav_date = NULL,
+ confirmed_proceeds = NULL,
+ confirmed_cost_released = NULL
+ WHERE id = @order_id
+ """
+
+ addParameter pendingCommand "reason" NpgsqlDbType.Text (box reason) |> ignore
+ addParameter pendingCommand "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+ pendingCommand.ExecuteNonQuery() |> ignore
+ transaction.Commit()
+
+ match findRedemptionOrder connection None orderId with
+ | Some pendingOrder -> RedemptionPendingNav pendingOrder
+ | None -> failwith "pending redemption disappeared after confirmation deferral"
+ | None ->
+ let nav = boundQuote |> Option.get |> fun quote -> quote.Nav
+
+ use positionCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ SELECT units, reserved_units, cost_cash
+ FROM fund_positions
+ WHERE fund_id = @fund_id AND instrument_code = @code
+ FOR UPDATE
+ """
+
+ addParameter positionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter positionCommand "code" NpgsqlDbType.Text (box order.InstrumentCode) |> ignore
+
+ use positionReader = positionCommand.ExecuteReader()
+ let positionFound = positionReader.Read()
+ let positionUnits = if positionFound then positionReader.GetDecimal(0) else 0m
+ let positionReserved = if positionFound then positionReader.GetDecimal(1) else 0m
+ let positionCost = if positionFound then positionReader.GetDecimal(2) else 0m
+ positionReader.Close()
+
+ if not positionFound || positionReserved < order.Units then
+ failwith "frozen units are missing for redemption confirmation"
+
+ match RedemptionPolicy.compute order.Units nav order.FeeAmount positionCost positionUnits with
+ | Error message ->
+ use pendingCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE redemption_orders
+ SET status = 'pending_nav',
+ pending_reason = @reason
+ WHERE id = @order_id
+ """
+
+ addParameter pendingCommand "reason" NpgsqlDbType.Text (box message) |> ignore
+ addParameter pendingCommand "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+ pendingCommand.ExecuteNonQuery() |> ignore
+ transaction.Commit()
+
+ match findRedemptionOrder connection None orderId with
+ | Some pendingOrder -> RedemptionPendingNav pendingOrder
+ | None -> failwith "pending redemption disappeared after computation failure"
+ | Ok computation ->
+ use settlePositionCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE fund_positions
+ SET units = units - @units,
+ reserved_units = reserved_units - @units,
+ cost_cash = cost_cash - @cost_released
+ WHERE fund_id = @fund_id
+ AND instrument_code = @code
+ AND units > @units
+ AND reserved_units >= @units
+ AND cost_cash >= @cost_released
+ """
+
+ addParameter settlePositionCommand "units" NpgsqlDbType.Numeric (box order.Units) |> ignore
+ addParameter settlePositionCommand "cost_released" NpgsqlDbType.Numeric (box computation.RedeemedCost) |> ignore
+ addParameter settlePositionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter settlePositionCommand "code" NpgsqlDbType.Text (box order.InstrumentCode) |> ignore
+
+ if settlePositionCommand.ExecuteNonQuery() = 0 then
+ use deletePositionCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ DELETE FROM fund_positions
+ WHERE fund_id = @fund_id
+ AND instrument_code = @code
+ AND units = @units
+ AND reserved_units >= @units
+ AND cost_cash >= @cost_released
+ """
+
+ addParameter deletePositionCommand "units" NpgsqlDbType.Numeric (box order.Units) |> ignore
+ addParameter deletePositionCommand "cost_released" NpgsqlDbType.Numeric (box computation.RedeemedCost) |> ignore
+ addParameter deletePositionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter deletePositionCommand "code" NpgsqlDbType.Text (box order.InstrumentCode) |> ignore
+
+ if deletePositionCommand.ExecuteNonQuery() = 0 then
+ failwith "position units changed during redemption confirmation"
+
+ use cashCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE funds
+ SET available_cash = available_cash + @proceeds
+ WHERE id = @fund_id
+ """
+
+ addParameter cashCommand "proceeds" NpgsqlDbType.Numeric (box computation.Proceeds) |> ignore
+ addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ if cashCommand.ExecuteNonQuery() = 0 then
+ failwith "fund disappeared during redemption confirmation"
+
+ use confirmCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE redemption_orders
+ SET status = 'confirmed',
+ pending_reason = NULL,
+ confirm_idempotency_key = @idempotency_key,
+ confirmed_at = @confirmed_at,
+ confirmed_nav = @nav,
+ confirmed_nav_date = @nav_date,
+ confirmed_proceeds = @proceeds,
+ confirmed_cost_released = @cost_released
+ WHERE id = @order_id
+ """
+
+ addParameter confirmCommand "idempotency_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ addParameter confirmCommand "confirmed_at" NpgsqlDbType.TimestampTz (box confirmedAt) |> ignore
+ addParameter confirmCommand "nav" NpgsqlDbType.Numeric (box nav) |> ignore
+ addParameter confirmCommand "nav_date" NpgsqlDbType.Date (box tradeDate) |> ignore
+ addParameter confirmCommand "proceeds" NpgsqlDbType.Numeric (box computation.Proceeds) |> ignore
+ addParameter confirmCommand "cost_released" NpgsqlDbType.Numeric (box computation.RedeemedCost) |> ignore
+ addParameter confirmCommand "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+ confirmCommand.ExecuteNonQuery() |> ignore
+
+ insertRedemptionConfirmIdempotency connection (Some transaction) idempotencyKey "" orderId fundId
+ transaction.Commit()
+
+ match findRedemptionOrder connection None orderId with
+ | Some confirmedOrder -> RedemptionConfirmed confirmedOrder
+ | None -> failwith "confirmed redemption disappeared after commit"
+
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
member _.ConfirmSubscriptionOrder(idempotencyKey: string, fundId: Guid, orderId: Guid) =
if String.IsNullOrWhiteSpace idempotencyKey then
ConfirmInvalid "idempotency key cannot be empty"
@@ -1556,7 +2232,7 @@ type FundRepository(connectionString: string) =
connection
None
"""
- SELECT p.instrument_code, p.units, p.cost_cash, p.last_confirmed_at,
+ SELECT p.instrument_code, p.units, p.reserved_units, p.cost_cash, p.last_confirmed_at,
q.nav, q.nav_date, q.source_collected_at
FROM fund_positions p
LEFT JOIN LATERAL (
@@ -1581,21 +2257,22 @@ type FundRepository(connectionString: string) =
while reader.Read() do
let valuationNav =
- if reader.IsDBNull(4) then None else Some(reader.GetDecimal(4))
+ if reader.IsDBNull(5) then None else Some(reader.GetDecimal(5))
let valuationNavDate =
- if reader.IsDBNull(5) then None else Some(reader.GetFieldValue<DateOnly>(5))
+ if reader.IsDBNull(6) then None else Some(reader.GetFieldValue<DateOnly>(6))
let valuationCollectedAt =
- if reader.IsDBNull(6) then None else Some(reader.GetFieldValue<DateTimeOffset>(6))
+ if reader.IsDBNull(7) then None else Some(reader.GetFieldValue<DateTimeOffset>(7))
records.Add(
{
FundId = fundId
InstrumentCode = reader.GetString(0)
Units = reader.GetDecimal(1)
- CostCash = reader.GetDecimal(2)
- LastConfirmedAt = reader.GetFieldValue<DateTimeOffset>(3)
+ ReservedUnits = reader.GetDecimal(2)
+ CostCash = reader.GetDecimal(3)
+ LastConfirmedAt = reader.GetFieldValue<DateTimeOffset>(4)
ValuationNav = valuationNav
ValuationNavDate = valuationNavDate
ValuationCollectedAt = valuationCollectedAt
diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj
index 9e46c00..a2c5c24 100644
--- a/src/FundLab.Domain/FundLab.Domain.fsproj
+++ b/src/FundLab.Domain/FundLab.Domain.fsproj
@@ -8,6 +8,7 @@
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Ledger.fs" />
+ <Compile Include="Redemption.fs" />
<Compile Include="Performance.fs" />
</ItemGroup>
</Project>
diff --git a/src/FundLab.Domain/Redemption.fs b/src/FundLab.Domain/Redemption.fs
new file mode 100644
index 0000000..a3e9188
--- /dev/null
+++ b/src/FundLab.Domain/Redemption.fs
@@ -0,0 +1,84 @@
+namespace FundLab.Domain
+
+open System
+
+module RedemptionPolicy =
+ let unitsMaximum = 99999999999999999999.99999999m
+ let cashMaximum = 999999999999999999.99m
+
+ let roundDown (scale: int) (value: decimal) : decimal =
+ let factor = decimal (pown 10 scale)
+ Decimal.Truncate(value * factor) / factor
+
+ let validateRequest
+ (availableUnits: decimal)
+ (requestedUnits: decimal)
+ (feeAmount: decimal)
+ : Result<unit, string> =
+ if requestedUnits <= 0m then
+ Error "redemption units must be positive"
+ elif requestedUnits > unitsMaximum then
+ Error "redemption units exceed supported precision"
+ elif requestedUnits > availableUnits then
+ Error "requested units exceed available holdings"
+ elif feeAmount < 0m then
+ Error "redemption fee cannot be negative"
+ elif feeAmount > cashMaximum then
+ Error "redemption fee exceeds supported precision"
+ else
+ Ok ()
+
+ type RedemptionComputation =
+ {
+ GrossCash: decimal
+ Fee: decimal
+ Proceeds: decimal
+ RedeemedCost: decimal
+ }
+
+ let compute
+ (requestedUnits: decimal)
+ (unitNav: decimal)
+ (feeAmount: decimal)
+ (positionCost: decimal)
+ (positionUnits: decimal)
+ : Result<RedemptionComputation, string> =
+ if unitNav <= 0m then
+ Error "unit nav must be positive"
+ elif unitNav < 0.00000001m then
+ Error "unit nav is below database precision"
+ elif requestedUnits <= 0m then
+ Error "redemption units must be positive"
+ elif positionUnits <= 0m then
+ Error "position units must be positive"
+ elif requestedUnits > positionUnits then
+ Error "redemption units exceed position units"
+ elif feeAmount < 0m then
+ Error "redemption fee cannot be negative"
+ else
+ let gross = requestedUnits * unitNav
+ let grossRounded = roundDown 2 gross
+
+ if grossRounded <= 0m then
+ Error "redemption cash rounds to zero"
+ elif feeAmount > grossRounded then
+ Error "redemption fee exceeds redemption value"
+ else
+ let proceeds = grossRounded - feeAmount
+
+ if proceeds <= 0m then
+ Error "redemption proceeds rounds to zero"
+ else
+ let redeemedCost =
+ if requestedUnits = positionUnits then
+ positionCost
+ else
+ roundDown 2 (positionCost * requestedUnits / positionUnits)
+
+ Ok
+ {
+ GrossCash = grossRounded
+ Fee = feeAmount
+ Proceeds = proceeds
+ RedeemedCost = redeemedCost
+ }
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 56b5a0c..7eb4469 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -152,6 +152,7 @@ type RawPosition =
{
instrumentCode: string
units: string
+ reservedUnits: string
costCash: string
lastConfirmedAt: string
valuationNav: obj
@@ -159,6 +160,23 @@ type RawPosition =
valuationCollectedAt: obj
}
+type RawRedemption =
+ {
+ id: string
+ instrumentCode: string
+ units: string
+ feeAmount: string
+ status: string
+ submittedAt: string
+ tradeDate: string
+ pendingReason: obj
+ confirmedAt: obj
+ confirmedNav: obj
+ confirmedNavDate: obj
+ confirmedProceeds: obj
+ confirmedCostReleased: obj
+ }
+
type RawPositions =
{
availableCash: string
@@ -187,6 +205,28 @@ type ConfirmAttempt =
orderId: string
}
+type RedemptionAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ units: string
+ feeAmount: string
+ }
+
+type RedemptionConfirmAttempt =
+ {
+ idempotencyKey: string
+ orderId: string
+ }
+
+type CreateRedemptionPayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ units: string
+ feeAmount: string
+ }
+
type OrderDetail =
{
id: string
@@ -210,12 +250,30 @@ type Position =
{
instrumentCode: string
units: string
+ reservedUnits: string
costCash: string
lastConfirmedAt: string
valuationNav: string option
valuationNavDate: string option
}
+type RedemptionDetail =
+ {
+ id: string
+ instrumentCode: string
+ units: string
+ feeAmount: string
+ status: string
+ submittedAt: string
+ tradeDate: string
+ pendingReason: string option
+ confirmedAt: string option
+ confirmedNav: string option
+ confirmedNavDate: string option
+ confirmedProceeds: string option
+ confirmedCostReleased: string option
+ }
+
type Positions =
{
availableCash: string
@@ -251,6 +309,15 @@ module Api =
[<Import("getPositions", "./src/api.js")>]
let getPositions (token: string) (fundId: string) : JS.Promise<RawPositions> = jsNative
+ [<Import("createRedemption", "./src/api.js")>]
+ let createRedemption (token: string) (fundId: string) (payload: CreateRedemptionPayload) : JS.Promise<RawRedemption> = jsNative
+
+ [<Import("getRedemptions", "./src/api.js")>]
+ let getRedemptions (token: string) (fundId: string) : JS.Promise<RawRedemption array> = jsNative
+
+ [<Import("confirmRedemption", "./src/api.js")>]
+ let confirmRedemption (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise<RawRedemption> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -308,6 +375,7 @@ module Api =
{
instrumentCode = raw.instrumentCode
units = raw.units
+ reservedUnits = raw.reservedUnits
costCash = raw.costCash
lastConfirmedAt = raw.lastConfirmedAt
valuationNav = decodeOptionalText raw.valuationNav
@@ -321,6 +389,23 @@ module Api =
positions = raw.positions |> Array.map decodePosition |> List.ofArray
}
+ let decodeRedemption (raw: RawRedemption) : RedemptionDetail =
+ {
+ id = raw.id
+ instrumentCode = raw.instrumentCode
+ units = raw.units
+ feeAmount = raw.feeAmount
+ status = raw.status
+ submittedAt = raw.submittedAt
+ tradeDate = raw.tradeDate
+ pendingReason = decodeOptionalText raw.pendingReason
+ confirmedAt = decodeOptionalText raw.confirmedAt
+ confirmedNav = decodeOptionalText raw.confirmedNav
+ confirmedNavDate = decodeOptionalText raw.confirmedNavDate
+ confirmedProceeds = decodeOptionalText raw.confirmedProceeds
+ confirmedCostReleased = decodeOptionalText raw.confirmedCostReleased
+ }
+
type Model =
{
token: string
@@ -352,6 +437,17 @@ type Model =
positions: Positions option
positionsSeq: int
positionsInFlight: bool
+ redemptionCode: string
+ redemptionUnits: string
+ redemptionFee: string
+ redemptionCreateSeq: int
+ redemptionReadSeq: int
+ redemptionInFlight: bool
+ lastRedemptionAttempt: RedemptionAttempt option
+ redemptions: RedemptionDetail list
+ redemptionConfirmSeq: int
+ redemptionConfirmInFlight: bool
+ lastRedemptionConfirmAttempt: RedemptionConfirmAttempt option
error: string option
}
@@ -389,6 +485,18 @@ type Msg =
| PositionsReadRequested
| PositionsReadCompleted of requestId: int * positions: RawPositions
| PositionsReadFailed of requestId: int * message: string
+ | RedemptionCodeChanged of string
+ | RedemptionUnitsChanged of string
+ | RedemptionFeeChanged of string
+ | RedemptionCreateRequested
+ | RedemptionCreateCompleted of requestId: int * fundId: string * order: RawRedemption
+ | RedemptionCreateFailed of requestId: int * fundId: string * message: string
+ | RedemptionsReadRequested
+ | RedemptionsReadCompleted of requestId: int * orders: RawRedemption array
+ | RedemptionsReadFailed of requestId: int * message: string
+ | RedemptionConfirmRequested of orderId: string
+ | RedemptionConfirmCompleted of requestId: int * orderId: string * order: RawRedemption
+ | RedemptionConfirmFailed of requestId: int * orderId: string * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -449,6 +557,17 @@ let init () =
positions = None
positionsSeq = 0
positionsInFlight = false
+ redemptionCode = ""
+ redemptionUnits = ""
+ redemptionFee = ""
+ redemptionCreateSeq = 0
+ redemptionReadSeq = 0
+ redemptionInFlight = false
+ lastRedemptionAttempt = None
+ redemptions = []
+ redemptionConfirmSeq = 0
+ redemptionConfirmInFlight = false
+ lastRedemptionConfirmAttempt = None
error = None
}
@@ -521,6 +640,27 @@ let private readPositionsCommand token fundId requestId =
(fun positions -> PositionsReadCompleted(requestId, positions))
(fun error -> PositionsReadFailed(requestId, errorText error))
+let private createRedemptionCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createRedemption token fundId payload)
+ ()
+ (fun order -> RedemptionCreateCompleted(requestId, fundId, order))
+ (fun error -> RedemptionCreateFailed(requestId, fundId, errorText error))
+
+let private readRedemptionsCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getRedemptions token fundId)
+ ()
+ (fun orders -> RedemptionsReadCompleted(requestId, orders))
+ (fun error -> RedemptionsReadFailed(requestId, errorText error))
+
+let private confirmRedemptionCommand token fundId orderId idempotencyKey requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.confirmRedemption token fundId orderId idempotencyKey)
+ ()
+ (fun order -> RedemptionConfirmCompleted(requestId, orderId, order))
+ (fun error -> RedemptionConfirmFailed(requestId, orderId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -554,6 +694,17 @@ let update message model =
positions = None
positionsSeq = model.positionsSeq + 1
positionsInFlight = false
+ redemptionCode = ""
+ redemptionUnits = ""
+ redemptionFee = ""
+ redemptionCreateSeq = model.redemptionCreateSeq + 1
+ redemptionReadSeq = model.redemptionReadSeq + 1
+ redemptionInFlight = false
+ lastRedemptionAttempt = None
+ redemptions = []
+ redemptionConfirmSeq = model.redemptionConfirmSeq + 1
+ redemptionConfirmInFlight = false
+ lastRedemptionConfirmAttempt = None
error = None
},
Cmd.none
@@ -713,6 +864,17 @@ let update message model =
positions = None
positionsSeq = model.positionsSeq + 1
positionsInFlight = false
+ redemptionCode = ""
+ redemptionUnits = ""
+ redemptionFee = ""
+ redemptionCreateSeq = model.redemptionCreateSeq + 1
+ redemptionReadSeq = model.redemptionReadSeq + 1
+ redemptionInFlight = false
+ lastRedemptionAttempt = None
+ redemptions = []
+ redemptionConfirmSeq = model.redemptionConfirmSeq + 1
+ redemptionConfirmInFlight = false
+ lastRedemptionConfirmAttempt = None
error = None
},
Cmd.ofMsg OrdersReadRequested
@@ -907,6 +1069,152 @@ let update message model =
{ model with positionsInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | RedemptionCodeChanged value ->
+ { model with redemptionCode = value; error = None; lastRedemptionAttempt = None }, Cmd.none
+ | RedemptionUnitsChanged value ->
+ { model with redemptionUnits = value; error = None; lastRedemptionAttempt = None }, Cmd.none
+ | RedemptionFeeChanged value ->
+ { model with redemptionFee = value; error = None; lastRedemptionAttempt = None }, Cmd.none
+ | RedemptionCreateRequested ->
+ let code = model.redemptionCode.Trim()
+ let units = model.redemptionUnits.Trim()
+ let fee = model.redemptionFee.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 Decimal.TryParse(units, NumberStyles.Float, CultureInfo.InvariantCulture) |> fst |> not then
+ { model with error = Some "赎回份额必须是合法的八位小数份额数,例如 100.00000000" }, Cmd.none
+ elif Decimal.Parse(units, NumberStyles.Float, CultureInfo.InvariantCulture) <= 0m then
+ { model with error = Some "赎回份额必须是大于零的八位小数份额数,例如 100.00000000" }, Cmd.none
+ elif not (isValidCashText fee) || not (isNonNegativeCash fee) then
+ { model with error = Some "赎回手续费必须是不小于零的两位小数金额,例如 0.00" }, Cmd.none
+ elif model.redemptionInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.redemptionCreateSeq + 1
+
+ let idempotencyKey =
+ match model.lastRedemptionAttempt with
+ | Some attempt when
+ attempt.instrumentCode = code
+ && attempt.units = units
+ && attempt.feeAmount = fee
+ ->
+ attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ redemptionCode = code
+ redemptionUnits = units
+ redemptionFee = fee
+ redemptionCreateSeq = requestId
+ redemptionInFlight = true
+ lastRedemptionAttempt =
+ Some
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ units = units
+ feeAmount = fee
+ }
+ error = None
+ },
+ createRedemptionCommand
+ model.token
+ model.createdFund.Value.id
+ {
+ idempotencyKey = idempotencyKey
+ instrumentCode = code
+ units = units
+ feeAmount = fee
+ }
+ requestId
+ | RedemptionCreateCompleted (requestId, fundId, order) ->
+ if requestId = model.redemptionCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ redemptionInFlight = false
+ lastRedemptionAttempt = None
+ error = None
+ },
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg RedemptionsReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ else
+ model, Cmd.none
+ | RedemptionCreateFailed (requestId, fundId, message) ->
+ if requestId = model.redemptionCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with redemptionInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | RedemptionsReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.redemptionReadSeq + 1
+
+ { model with redemptionReadSeq = requestId; error = None },
+ readRedemptionsCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ model, Cmd.none
+ | RedemptionsReadCompleted (requestId, orders) ->
+ if requestId = model.redemptionReadSeq then
+ {
+ model with
+ redemptions = orders |> Array.map Api.decodeRedemption |> Array.toList
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | RedemptionsReadFailed (requestId, message) ->
+ if requestId = model.redemptionReadSeq then
+ { model with error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | RedemptionConfirmRequested orderId ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.redemptionConfirmInFlight ->
+ let requestId = model.redemptionConfirmSeq + 1
+ let idempotencyKey = Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ redemptionConfirmSeq = requestId
+ redemptionConfirmInFlight = true
+ lastRedemptionConfirmAttempt = Some { idempotencyKey = idempotencyKey; orderId = orderId }
+ error = None
+ },
+ confirmRedemptionCommand model.token fund.id orderId idempotencyKey requestId
+ | Some _ when model.redemptionConfirmInFlight -> model, Cmd.none
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | RedemptionConfirmCompleted (requestId, orderId, raw) ->
+ if requestId = model.redemptionConfirmSeq
+ && (model.lastRedemptionConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then
+ let confirmed = Api.decodeRedemption raw
+
+ let redemptions =
+ model.redemptions
+ |> List.map (fun order -> if order.id = confirmed.id then confirmed else order)
+
+ { model with redemptionConfirmInFlight = false; lastRedemptionConfirmAttempt = None; redemptions = redemptions; error = None },
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ else
+ model, Cmd.none
+ | RedemptionConfirmFailed (requestId, orderId, message) ->
+ if requestId = model.redemptionConfirmSeq
+ && (model.lastRedemptionConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then
+ { model with redemptionConfirmInFlight = false; lastRedemptionConfirmAttempt = None; error = Some message },
+ Cmd.ofMsg RedemptionsReadRequested
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -1386,11 +1694,18 @@ let private positionRow (position: Position) =
| Some nav, Some navDate -> sprintf "%s(%s)" nav navDate
| _ -> "估值待更新"
+ let frozen =
+ if position.reservedUnits <> "0.00000000" then
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "冻结份额 %s" position.reservedUnits) ]
+ else
+ Html.none
+
Html.div [
prop.className "order-row position-row"
prop.children [
Html.span [ prop.className "order-code"; prop.text position.instrumentCode ]
Html.span [ prop.className "order-cell"; prop.text (sprintf "份额 %s" position.units) ]
+ frozen
Html.span [ prop.className "order-cell"; prop.text (sprintf "成本 %s" position.costCash) ]
Html.span [ prop.className "order-cell"; prop.text (sprintf "最新估值净值 %s" valuation) ]
Html.span [ prop.className "order-cell"; prop.text (sprintf "最近确认 %s" position.lastConfirmedAt) ]
@@ -1443,6 +1758,139 @@ let private positionsPanel model dispatch =
]
]
+let private redemptionStatusText (status: string) =
+ if status = "submitted" then "已提交 · 待确认"
+ elif status = "pending_nav" then "等待净值"
+ elif status = "confirmed" then "已确认"
+ else status
+
+let private redemptionRow (order: RedemptionDetail) model dispatch =
+ let confirmButton =
+ if order.status = "submitted" || order.status = "pending_nav" then
+ Html.button [
+ prop.className "secondary-action redemption-confirm-action"
+ prop.disabled model.redemptionConfirmInFlight
+ prop.onClick (fun _ -> dispatch (RedemptionConfirmRequested order.id))
+ prop.text (if model.redemptionConfirmInFlight then "确认中..." else "确认赎回")
+ ]
+ else
+ Html.none
+
+ let detailText =
+ match order.status with
+ | "confirmed" ->
+ [
+ sprintf "确认净值 %s" (order.confirmedNav |> Option.defaultValue "—")
+ sprintf "赎回到账 %s" (order.confirmedProceeds |> Option.defaultValue "—")
+ sprintf "核销成本 %s" (order.confirmedCostReleased |> Option.defaultValue "—")
+ sprintf "净值日期 %s" (order.confirmedNavDate |> Option.defaultValue "—")
+ ]
+ |> String.concat " · "
+ | "pending_nav" ->
+ "等待净值 · " + (order.pendingReason |> Option.defaultValue "净值尚未公布")
+ | _ -> "已冻结份额;净值确认后按交易日结算到账。"
+
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text order.instrumentCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "份额 %s" order.units) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "交易日 %s" order.tradeDate) ]
+ Html.span [ prop.className "order-status"; prop.text (redemptionStatusText order.status) ]
+ Html.span [ prop.className "order-detail"; prop.text detailText ]
+ confirmButton
+ Html.span [ prop.className "order-cell"; prop.text order.submittedAt ]
+ ]
+ ]
+
+let private redeemPanel model dispatch =
+ Html.section [
+ prop.className "panel redeem-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "06 / REDEEM" ]
+ Html.h2 "赎回已确认持仓(模拟)"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "Pending · units frozen" ]
+ ]
+ ]
+ 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 redemption-code-input"
+ prop.placeholder "六位基金代码"
+ prop.value model.redemptionCode
+ prop.onChange (fun value -> dispatch (RedemptionCodeChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "赎回份额"
+ Html.input [
+ prop.className "text-input redemption-units-input"
+ prop.placeholder "例如 100.00000000"
+ prop.value model.redemptionUnits
+ prop.onChange (fun value -> dispatch (RedemptionUnitsChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "赎回手续费(元)"
+ Html.input [
+ prop.className "text-input redemption-fee-input"
+ prop.placeholder "0.00 或 1.50"
+ prop.value model.redemptionFee
+ prop.onChange (fun value -> dispatch (RedemptionFeeChanged value))
+ ]
+ ]
+ ]
+ Html.button [
+ prop.className "primary-action redemption-submit-action"
+ prop.disabled model.redemptionInFlight
+ prop.onClick (fun _ -> dispatch RedemptionCreateRequested)
+ prop.text ((if model.redemptionInFlight then "赎回中..." else "提交赎回"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "份额与手续费按小数字符串提交;提交即冻结对应份额,确认前不产生任何现金变化。"
+ ]
+ Html.div [
+ prop.className "pending-redemptions"
+ prop.children [
+ if List.isEmpty model.redemptions then
+ Html.p [ prop.className "hint"; prop.text "暂无赎回订单" ]
+ else
+ yield! (model.redemptions |> List.map (fun order -> redemptionRow order model dispatch))
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action redemptions-refresh-action"
+ prop.onClick (fun _ -> dispatch RedemptionsReadRequested)
+ prop.text "刷新赎回订单"
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -1500,6 +1948,7 @@ let view model dispatch =
fundPanel model dispatch
subscribePanel model dispatch
positionsPanel model dispatch
+ redeemPanel 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 bda8536..743229c 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -87,3 +87,34 @@ export function confirmOrder(token, fundId, orderId, idempotencyKey) {
export function getPositions(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/positions`, token);
}
+
+export function createRedemption(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"units":${JSON.stringify(payload.units)},"feeAmount":${JSON.stringify(payload.feeAmount)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/redemptions`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getRedemptions(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/redemptions`, token);
+}
+
+export function confirmRedemption(token, fundId, orderId, idempotencyKey) {
+ return requestJson(
+ `/api/funds/${encodeURIComponent(fundId)}/redemptions/${encodeURIComponent(orderId)}/confirm`,
+ token,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey
+ },
+ body: "{}"
+ }
+ );
+}
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index d9725d5..4fc1da0 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -1547,3 +1547,336 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) =
Assert.Equal(200, cashStatus)
Assert.Contains("\"availableCash\":\"9000.00\"", cashBody)
+
+[<Collection("postgres")>]
+type RedemptionTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.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, "redemption-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "赎回测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "redemption-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 truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) (collectedAt: DateTimeOffset) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = collectedAt
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "redemption-quote-hash/%s" revision)
+
+ let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) =
+ insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt
+
+ let execute (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ for name, value, dbType in parameters do
+ let parameter = command.Parameters.Add(name, dbType)
+ parameter.Value <- value
+
+ command.ExecuteNonQuery() |> ignore
+
+ let scalarDecimal (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ for name, value, dbType in parameters do
+ let parameter = command.Parameters.Add(name, dbType)
+ parameter.Value <- value
+
+ command.ExecuteScalar() :?> decimal
+
+ let availableCash fundId =
+ scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ let reservedCash fundId =
+ scalarDecimal "SELECT reserved_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ let positionValue fundId code column =
+ scalarDecimal
+ (sprintf "SELECT %s FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" column)
+ [
+ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid
+ "code", box code, NpgsqlTypes.NpgsqlDbType.Text
+ ]
+
+ let postOrder fundId idempotencyKey amount fee =
+ let code = seedInstrument ()
+ let body = sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" code amount fee
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+ |> fun (status, response) ->
+ if status <> 201 then failwithf "unexpected order status %d: %s" status response
+ code, PersistenceTestHelpers.responseId response
+
+ let confirmOrder (fundId: Guid) (orderId: Guid) (code: string) =
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected
+ let key = fixture.Key(sprintf "confirm-%s" (Guid.NewGuid().ToString("N")))
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", key
+ ]
+ ""
+ |> fun (status, response) ->
+ if status <> 200 then failwithf "unexpected confirm status %d: %s" status response
+
+ let postRedemption (fundId: Guid) (code: string) (idempotencyKey: string) (units: string) (fee: string) =
+ let body =
+ sprintf
+ "{\"instrumentCode\":\"%s\",\"units\":\"%s\",\"feeAmount\":\"%s\"}"
+ code
+ units
+ fee
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/redemptions" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+
+ let confirmRedemptionOverHttp (fundId: Guid) (orderId: Guid) (idempotencyKey: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/redemptions/%O/confirm" fundId orderId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ ""
+
+ let getRedemptionsOverHttp (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/redemptions" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let confirmedSubscription fundId initialCash amount =
+ let code, orderId = postOrder fundId (fixture.Key(sprintf "sub-%s" (Guid.NewGuid().ToString("N")))) amount "0.00"
+ confirmOrder fundId orderId code
+ code
+
+ [<Fact>]
+ member _.``redemption submission freezes holding units without touching cash``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postRedemption fundId code key "150.00000000" "1.50"
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"status\":\"submitted\"", response)
+ Assert.Contains("\"units\":\"150.00000000\"", response)
+ Assert.Contains("\"feeAmount\":\"1.50\"", response)
+
+ Assert.Equal(9000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+ Assert.Equal(400.00000000m, positionValue fundId code "units")
+ Assert.Equal(150.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(1000.00m, positionValue fundId code "cost_cash")
+
+ [<Fact>]
+ member _.``redemption above available units is rejected without freezing``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postRedemption fundId code key "400.00000001" "0.00"
+
+ Assert.Equal(409, status)
+ Assert.Contains("INSUFFICIENT_UNITS", response)
+ Assert.Equal(0.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(400.00000000m, positionValue fundId code "units")
+
+ [<Fact>]
+ member _.``redemption replay returns the same order without double freezing``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let firstStatus, firstResponse = postRedemption fundId code key "100.00000000" "0.50"
+ let secondStatus, secondResponse = postRedemption fundId code key "100.00000000" "0.50"
+
+ Assert.Equal(201, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+ Assert.Equal(100.00000000m, positionValue fundId code "reserved_units")
+
+ let conflictingStatus, conflictingResponse = postRedemption fundId code key "50.00000000" "0.50"
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse)
+ Assert.Equal(100.00000000m, positionValue fundId code "reserved_units")
+
+ [<Fact>]
+ member _.``redemption confirmation settles cash and writes off position``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code key "150.00000000" "1.50"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"status\":\"confirmed\"", response)
+ Assert.Contains("\"confirmedNav\":\"2.50000000\"", response)
+ Assert.Contains("\"confirmedProceeds\":\"373.50\"", response)
+ Assert.Contains("\"confirmedCostReleased\":\"375.00\"", response)
+
+ Assert.Equal(9373.50m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+ Assert.Equal(250.00000000m, positionValue fundId code "units")
+ Assert.Equal(0.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(625.00m, positionValue fundId code "cost_cash")
+
+ [<Fact>]
+ member _.``full redemption removes the position row while cash conservation holds``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code key "400.00000000" "0.00"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"confirmedProceeds\":\"1000.00\"", response)
+ Assert.Contains("\"confirmedCostReleased\":\"1000.00\"", response)
+
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT count(*) FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code"
+ let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ fundParameter.Value <- box fundId
+ let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text)
+ codeParameter.Value <- box code
+ let remaining = command.ExecuteScalar() :?> int64
+
+ Assert.Equal(0L, remaining)
+ Assert.Equal(10000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+
+ [<Fact>]
+ member _.``redemption confirm replay keeps cash and positions stable``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code submitKey "80.00000000" "0.80"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let confirmKey = fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))
+ let firstStatus, firstResponse = confirmRedemptionOverHttp fundId orderId confirmKey
+ let secondStatus, secondResponse = confirmRedemptionOverHttp fundId orderId confirmKey
+
+ Assert.Equal(200, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+ Assert.Equal(9000.00m + 199.20m, availableCash fundId)
+ Assert.Equal(320.00000000m, positionValue fundId code "units")
+
+ let conflictingStatus, conflictingResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("ORDER_ALREADY_CONFIRMED", conflictingResponse)
+ Assert.Equal(9000.00m + 199.20m, availableCash fundId)
+
+ [<Fact>]
+ member _.``redemption list exposes confirmation evidence``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code submitKey "40.00000000" "0.40"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let _, confirmResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+ let listStatus, listResponse = getRedemptionsOverHttp fundId
+
+ Assert.Equal(200, listStatus)
+ Assert.Contains("\"instrumentCode\":\"" + code + "\"", listResponse)
+ Assert.Contains("\"status\":\"confirmed\"", listResponse)
+ Assert.Contains("\"confirmedProceeds\":\"99.60\"", listResponse)
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index f2b4138..6a9fc66 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -491,3 +491,51 @@ module DomainTests =
Assert.Equal(Error(InvalidObservation "net assets cannot be negative"), negative)
Assert.Equal(Error(InvalidObservation "first net assets must be positive"), zero)
+
+module RedemptionPolicyTests =
+
+ open Xunit
+ open FundLab.Domain
+
+ let private unwrap result =
+ match result with
+ | Ok value -> value
+ | Error error -> failwithf "%A" error
+
+ [<Fact>]
+ let ``redemption request above available units is rejected`` () =
+ Assert.Equal(Error "requested units exceed available holdings", RedemptionPolicy.validateRequest 10.00000000m 10.00000001m 0.00m)
+ Assert.Equal(Error "redemption units must be positive", RedemptionPolicy.validateRequest 10.00000000m 0m 0.00m)
+ Assert.Equal(Error "redemption fee cannot be negative", RedemptionPolicy.validateRequest 10.00000000m 5.00000000m -0.01m)
+ Assert.Equal(Ok(), RedemptionPolicy.validateRequest 10.00000000m 10.00000000m 1.50m)
+
+ [<Fact>]
+ let ``full redemption releases the entire position cost`` () =
+ let computed = RedemptionPolicy.compute 40.00000000m 2.5m 1.00m 100.00m 40.00000000m |> unwrap
+
+ Assert.Equal(100.00m, computed.GrossCash)
+ Assert.Equal(1.00m, computed.Fee)
+ Assert.Equal(99.00m, computed.Proceeds)
+ Assert.Equal(100.00m, computed.RedeemedCost)
+
+ [<Fact>]
+ let ``partial redemption writes off cost pro rata rounded down`` () =
+ let computed = RedemptionPolicy.compute 10.00000000m 2.5m 0.00m 100.00m 40.00000000m |> unwrap
+
+ Assert.Equal(25.00m, computed.GrossCash)
+ Assert.Equal(0.00m, computed.Fee)
+ Assert.Equal(25.00m, computed.Proceeds)
+ Assert.Equal(25.00m, computed.RedeemedCost)
+
+ let uneven = RedemptionPolicy.compute 1.00000000m 3m 0.00m 100.00m 3.00000000m |> unwrap
+
+ Assert.Equal(3.00m, uneven.GrossCash)
+ Assert.Equal(33.33m, uneven.RedeemedCost)
+
+ [<Fact>]
+ let ``redemption computation rejects invalid economics`` () =
+ Assert.Equal(Error "redemption units exceed position units", RedemptionPolicy.compute 41.00000000m 2.5m 0.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "unit nav must be positive", RedemptionPolicy.compute 1.00000000m 0m 0.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption fee exceeds redemption value", RedemptionPolicy.compute 1.00000000m 2.5m 3.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption proceeds rounds to zero", RedemptionPolicy.compute 0.00000001m 1500000m 0.01m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption cash rounds to zero", RedemptionPolicy.compute 0.00000001m 2m 0.00m 100.00m 40.00000000m)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index d48d08b..015ed45 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -379,6 +379,7 @@ module ConfirmationBoundaryTests =
[|
{ instrumentCode = "000001"
units = "40.00000000"
+ reservedUnits = "0.00000000"
costCash = "100.00"
lastConfirmedAt = "2026-09-21T11:00:00+00:00"
valuationNav = someText "2.61000000"
@@ -386,6 +387,7 @@ module ConfirmationBoundaryTests =
valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" }
{ instrumentCode = "110011"
units = "10.00000000"
+ reservedUnits = "5.00000000"
costCash = "25.00"
lastConfirmedAt = "2026-09-21T11:00:00+00:00"
valuationNav = null
@@ -559,3 +561,77 @@ module ConfirmationBoundaryTests =
let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared
Assert.Equal(None, stalePositions.positions)
+
+module RedemptionBoundaryTests =
+
+ let sampleRedemptionRaw (status: string) : RawRedemption =
+ {
+ id = "9b2f1c3d-0000-0000-0000-000000000001"
+ instrumentCode = "000001"
+ units = "150.00000000"
+ feeAmount = "1.50"
+ status = status
+ submittedAt = "2026-09-21T10:00:00+00:00"
+ tradeDate = "2026-09-21"
+ pendingReason = null
+ confirmedAt = null
+ confirmedNav = null
+ confirmedNavDate = null
+ confirmedProceeds = null
+ confirmedCostReleased = null
+ }
+
+ let confirmedRedemptionRaw () : RawRedemption =
+ let someText (value: string) : obj = box { case = "Some"; fields = [| value |] }
+
+ { sampleRedemptionRaw "confirmed" with
+ pendingReason = null
+ confirmedAt = someText "2026-09-21T11:00:00+00:00"
+ confirmedNav = someText "2.50000000"
+ confirmedNavDate = someText "2026-09-21"
+ confirmedProceeds = someText "373.50"
+ confirmedCostReleased = someText "375.00" }
+
+ [<Fact>]
+ let ``redemption decode keeps decimal strings and option fields`` () =
+ let confirmed = Api.decodeRedemption (confirmedRedemptionRaw ())
+
+ Assert.Equal("confirmed", confirmed.status)
+ Assert.Equal("150.00000000", confirmed.units)
+ Assert.Equal("1.50", confirmed.feeAmount)
+ Assert.Equal("2.50000000", confirmed.confirmedNav.Value)
+ Assert.Equal("373.50", confirmed.confirmedProceeds.Value)
+ Assert.Equal("375.00", confirmed.confirmedCostReleased.Value)
+ Assert.Equal(None, confirmed.pendingReason)
+
+ let sampleRedemptionPositions () : RawPositions =
+ {
+ availableCash = "9900.00"
+ reservedCash = "101.50"
+ positions =
+ [|
+ { instrumentCode = "000001"
+ units = "40.00000000"
+ reservedUnits = "0.00000000"
+ costCash = "100.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = null
+ valuationNavDate = null
+ valuationCollectedAt = null }
+ { instrumentCode = "110011"
+ units = "10.00000000"
+ reservedUnits = "5.00000000"
+ costCash = "25.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = null
+ valuationNavDate = null
+ valuationCollectedAt = null }
+ |]
+ }
+
+ [<Fact>]
+ let ``positions decode keeps reserved units for frozen shares`` () =
+ let decoded = Api.decodePositions (sampleRedemptionPositions ())
+
+ Assert.Equal("0.00000000", decoded.positions[0].reservedUnits)
+ Assert.Equal("5.00000000", decoded.positions[1].reservedUnits)