diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 143 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 689 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Domain/Redemption.fs | 84 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 449 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 31 |
6 files changed, 1391 insertions, 6 deletions
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: "{}" + } + ); +} |
