summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 11:21:51 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 11:21:51 +0800
commitb8811922f4e167d50efb8817da35a12d8a9d653b (patch)
treeb85cc56677be5d4d58ad2277f00cbc935933301e
parent3d9398234a54774544eb48a1caed6a1b2d5132eb (diff)
downloadfund-lab-b8811922f4e167d50efb8817da35a12d8a9d653b.tar.gz
Add redemption order minimal closed loop (3d-3)
Redeem confirmed holdings end to end with exact decimal semantics: submission freezes position units (reserved_units) after an available-units check without touching cash, confirmation settles at the trade-date NAV with evidence and deferral rules identical to subscriptions, credits the fund with proceeds net of the stated fee, and writes off position cost pro rata (full redemption removes the position row). Cash conservation holds at every step: frozen shares and receivable cash never appear as available cash before settlement. Domain gains RedemptionPolicy (request validation plus proceeds/fee/ cost-release computation) with unit tests. Persistence adds redemption_orders, idempotency tables, reserved_units on positions and Create/Confirm repository members; the API exposes POST/GET /funds/{id}/redemptions and POST .../confirm. The frontend adds a 06/REDEEM panel (submit, confirm, pending reasons, confirmed proceeds/cost display), frozen-share display on the holdings panel, and refresh wiring. Browser QA gains a K-series covering freeze, settlement, cash accounting, replay and over-redemption rejection.
-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)