diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 09:04:47 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 09:04:47 +0800 |
| commit | d2344b325be6afb436b81ba33312cfe15040d0fa (patch) | |
| tree | 3c9e835078c521dbad1d0710870c68bb92c9a8e4 /src/FundLab.Api/App.fs | |
| parent | 72f466b9b3ea6ece726e6b182cfc36bbd3f6fa15 (diff) | |
| download | fund-lab-d2344b325be6afb436b81ba33312cfe15040d0fa.tar.gz | |
Add stock cashflow ledger with buy/sell events and idempotent dividend recording (3d-30 B3)
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 137 |
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) |
