summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs137
1 files changed, 137 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index a77fdf5..f45f7ae 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -174,6 +174,27 @@ type StockSellResponse =
isSynthetic: bool
}
+type StockCashflowResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ stockName: string option
+ eventType: string
+ eventDate: string
+ quantity: string
+ amount: string
+ note: string option
+ isSynthetic: bool
+ createdAt: string
+ }
+
+type StockCashflowsResponse =
+ {
+ fundId: Guid
+ events: StockCashflowResponse list
+ }
+
type BondTradeResponse =
{
id: Guid
@@ -724,6 +745,21 @@ module App =
isSynthetic = sell.IsSynthetic
}
+ let private stockCashflowResponse (record: StockCashflowRecord) : StockCashflowResponse =
+ {
+ id = record.Id
+ fundId = record.FundId
+ instrumentCode = record.InstrumentCode
+ stockName = record.StockName
+ eventType = record.EventType
+ eventDate = dateText record.EventDate
+ quantity = decimalText record.Quantity
+ amount = cashText record.Amount
+ note = record.Note
+ isSynthetic = record.IsSynthetic
+ createdAt = timestampText record.CreatedAt
+ }
+
let private stockPositionResponse (position: StockPositionRecord) : StockPositionResponse =
{
instrumentCode = position.InstrumentCode
@@ -2396,6 +2432,105 @@ module App =
return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock trade persistence failed") next ctx
}
+ let private parseStockCashflowCommand (body: string) : Result<StockCashflowCommand, 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 "eventType",
+ tryStringProperty root "eventDate"
+ with
+ | Some code, Some eventType, Some eventDateText ->
+ if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then
+ Error "instrumentCode must contain exactly six digits"
+ else
+ match DateOnly.TryParseExact(eventDateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | false, _ -> Error "eventDate must be an ISO date (yyyy-MM-dd)"
+ | true, eventDate ->
+ let quantity =
+ match tryStringProperty root "quantity" with
+ | None -> Ok 0m
+ | Some text -> tryDecimal "quantity" text
+
+ let amount =
+ match tryStringProperty root "amount" with
+ | None -> Ok 0m
+ | Some text -> tryDecimal "amount" text
+
+ match quantity, amount with
+ | Error message, _ -> Error message
+ | _, Error message -> Error message
+ | Ok quantity, Ok amount ->
+ Ok
+ {
+ InstrumentCode = code.Trim()
+ StockName = tryStringProperty root "stockName"
+ EventType = eventType
+ EventDate = eventDate
+ Quantity = quantity
+ Amount = amount
+ Note = tryStringProperty root "note"
+ }
+ | _ -> Error "instrumentCode, eventType and eventDate are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
+ let private recordStockCashflow (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_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 parseStockCashflowCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.RecordStockCashflow(idempotencyKey, fundId, command) with
+ | StockCashflowWriteResult.StockCashflowCreated record ->
+ return! invokeHandler (setStatusCode 201 >=> json (stockCashflowResponse record)) next ctx
+ | StockCashflowWriteResult.StockCashflowReplayed record ->
+ return! invokeHandler (json (stockCashflowResponse record)) next ctx
+ | StockCashflowWriteResult.StockCashflowIdempotencyConflict ->
+ return!
+ invokeHandler
+ (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request")
+ next
+ ctx
+ | StockCashflowWriteResult.StockCashflowInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_REQUEST" message) next ctx
+ | StockCashflowWriteResult.StockCashflowFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock cashflow persistence failed") next ctx
+ }
+
+ let private getStockCashflows (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_STOCK_CASHFLOW_REQUEST" "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 fund ->
+ let events =
+ repository.GetStockCashflows fundId |> List.map stockCashflowResponse
+
+ json ({ fundId = fund.Id; events = events } : StockCashflowsResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "stock cashflow persistence failed" next ctx
+
let private getStockPositions (repository: FundRepository) (fundIdText: string) : HttpHandler =
fun next ctx ->
match Guid.TryParse fundIdText with
@@ -3404,6 +3539,8 @@ module App =
POST >=> routef "/funds/%s/stock-trades" (createStockTrade repository probes)
POST >=> routef "/funds/%s/stock-sells" (createStockSell repository probes)
GET >=> routef "/funds/%s/stock-positions" (getStockPositions repository)
+ POST >=> routef "/funds/%s/stock-cashflows" (recordStockCashflow repository)
+ GET >=> routef "/funds/%s/stock-cashflows" (getStockCashflows repository)
POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes)
GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository)
POST >=> routef "/funds/%s/bond-sells" (createBondSell repository probes)