diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
| commit | 9818daebaf6fb99124a3196f880b3b8a7998d3a6 (patch) | |
| tree | 0b027703ff1ff164a89965723cfd9d5a3fee5081 /src | |
| parent | 8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff) | |
| download | fund-lab-9818daebaf6fb99124a3196f880b3b8a7998d3a6.tar.gz | |
Add cash ledger event-source breakdown and reconciliation API/UI (3d-31 B4c)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 77 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 223 | ||||
| -rw-r--r-- | src/FundLab.Domain/CashLedger.fs | 118 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 240 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 |
6 files changed, 661 insertions, 2 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index d5c3a50..3d89dae 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -487,6 +487,35 @@ type CapitalDepositResponse = createdAt: string } +type CashLedgerEventResponse = + { + source: string + eventDate: string + amount: string + referenceType: string + referenceId: string + note: string option + } + +type CashLedgerSourceResponse = + { + source: string + netAmount: string + eventCount: int + } + +type CashReconciliationResponse = + { + fundId: Guid + openingCash: string + netInflow: string + closingCash: string + ledgerBalance: string + difference: string + sources: CashLedgerSourceResponse list + events: CashLedgerEventResponse list + } + type FundReturnsPointResponse = { date: string @@ -846,6 +875,39 @@ module App = createdAt = timestampText deposit.CreatedAt } + let private cashLedgerEventResponse (event: CashLedger.CashLedgerEvent) : CashLedgerEventResponse = + { + source = CashLedger.sourceText event.Source + eventDate = dateText event.EventDate + amount = cashText event.Amount + referenceType = event.ReferenceType + referenceId = event.ReferenceId + note = event.Note + } + + let private cashReconciliationResponse + (fundId: Guid) + (reconciliation: CashLedger.CashReconciliation) + (events: CashLedger.CashLedgerEvent list) + : CashReconciliationResponse = + { + fundId = fundId + openingCash = cashText reconciliation.OpeningCash + netInflow = cashText reconciliation.NetInflow + closingCash = cashText reconciliation.ClosingCash + ledgerBalance = cashText reconciliation.LedgerBalance + difference = cashText reconciliation.Difference + sources = + reconciliation.Sources + |> List.map (fun total -> + { + source = CashLedger.sourceText total.Source + netAmount = cashText total.NetAmount + eventCount = total.EventCount + }) + events = events |> List.map cashLedgerEventResponse + } + let private sipPlanResponse (plan: SipPlanRecord) : SipPlanResponse = { id = plan.Id @@ -1763,6 +1825,20 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "capital deposit persistence failed" next ctx + let private getCashReconciliation (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.GetCashReconciliation fundId with + | Error _ -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Ok reconciliation -> + let events = repository.GetCashLedger fundId + json (cashReconciliationResponse fundId reconciliation events) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "cash reconciliation failed" next ctx + let private createSipPlan (repository: FundRepository) (fundIdText: string) : HttpHandler = fun next ctx -> task { @@ -3555,6 +3631,7 @@ module App = GET >=> routef "/funds/%s/positions" (getPositions repository) POST >=> routef "/funds/%s/capital/deposit" (createCapitalDeposit repository) GET >=> routef "/funds/%s/capital/deposits" (getCapitalDeposits repository) + GET >=> routef "/funds/%s/cash-reconciliation" (getCashReconciliation repository) POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository probes) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 267eff3..47c4312 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -1019,6 +1019,22 @@ type FundRepository(connectionString: string) = created_at timestamptz NOT NULL DEFAULT now() ); + CREATE TABLE IF NOT EXISTS cash_ledger_events ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + source_category text NOT NULL, + event_date date NOT NULL, + amount numeric(20, 2) NOT NULL, + reference_type text NOT NULL, + reference_id text NOT NULL, + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (fund_id, reference_type, reference_id) + ); + + CREATE INDEX IF NOT EXISTS cash_ledger_events_fund_idx + ON cash_ledger_events (fund_id, event_date, created_at); + CREATE TABLE IF NOT EXISTS sip_plans ( id uuid PRIMARY KEY, fund_id uuid NOT NULL REFERENCES funds(id), @@ -1683,6 +1699,18 @@ type FundRepository(connectionString: string) = addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore command.ExecuteNonQuery() |> ignore + let findOrderIdempotencyKeyByOrderId connection transaction orderId = + use command = + commandWithTransaction + connection + transaction + "SELECT idempotency_key FROM subscription_order_idempotencies WHERE order_id = @order_id LIMIT 1" + + addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(reader.GetString(0)) else None + let upsertInstrument connection transaction (payload: MarketDataSearchPayload) payloadHash (instrument: MarketDataInstrument) = use command = commandWithTransaction @@ -2134,6 +2162,48 @@ type FundRepository(connectionString: string) = addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore command.ExecuteNonQuery() |> ignore + /// Append a signed cash movement to the ledger. The unique key on + /// (fund_id, reference_type, reference_id) makes replays no-ops, so a retried + /// operation never double-counts. Callers pass the same delta as the cash UPDATE. + let insertCashLedgerEvent + connection + transaction + (fundId: Guid) + (source: CashLedger.CashEventSource) + (eventDate: DateOnly) + (amount: decimal) + (referenceType: string) + (referenceId: string) + (note: string option) + = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO cash_ledger_events + (id, fund_id, source_category, event_date, amount, reference_type, reference_id, note) + VALUES + (@id, @fund_id, @source_category, @event_date, @amount, @reference_type, @reference_id, @note) + ON CONFLICT (fund_id, reference_type, reference_id) DO NOTHING + """ + + addParameter command "id" NpgsqlDbType.Uuid (box (Guid.NewGuid())) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + addParameter command "source_category" NpgsqlDbType.Text (box (CashLedger.sourceText source)) |> ignore + addParameter command "event_date" NpgsqlDbType.Date (box eventDate) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box amount) |> ignore + addParameter command "reference_type" NpgsqlDbType.Text (box referenceType) |> ignore + addParameter command "reference_id" NpgsqlDbType.Text (box referenceId) |> ignore + + let noteParameter = + match note with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "note" NpgsqlDbType.Text noteParameter |> ignore + command.ExecuteNonQuery() |> ignore + let capitalDepositRequestHash (fundId: Guid) (command: CapitalDepositCommand) = let invariant = CultureInfo.InvariantCulture let encoded (value: string) = sprintf "%d:%s" value.Length value @@ -5640,6 +5710,18 @@ type FundRepository(connectionString: string) = let createdAt = insertDividendRecord connection (Some transaction) record insertDividendIdempotency connection (Some transaction) schemeKey fingerprint recordId fundId + + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.Dividend + command.NavDate + payout.GrossCash + "dividend" + (recordId.ToString("D")) + None + transaction.Commit() StageBooked { record with CreatedAt = createdAt } with error -> @@ -5843,6 +5925,18 @@ type FundRepository(connectionString: string) = let createdAt = insertCapitalDeposit connection (Some transaction) deposit insertCapitalDepositIdempotency connection (Some transaction) idempotencyKey fingerprint deposit.Id fundId + + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.CapitalDeposit + (ConfirmationPolicy.eventDateFor deposit.CreatedAt) + command.Amount + "capital_deposit" + (deposit.Id.ToString("D")) + None + transaction.Commit() CapitalDepositWriteResult.CapitalDepositCreated { deposit with CreatedAt = createdAt } with error -> @@ -5943,6 +6037,18 @@ type FundRepository(connectionString: string) = insertStockTrade connection (Some transaction) trade insertStockTradeIdempotency connection (Some transaction) idempotencyKey fingerprint trade.Id fundId + if defaultArg debitAvailableCash false then + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.StockBuy + (DateOnly.FromDateTime executedAt.UtcDateTime) + -costCash + "stock_buy" + (trade.Id.ToString("D")) + None + use positionCommand = commandWithTransaction connection @@ -6354,6 +6460,17 @@ type FundRepository(connectionString: string) = insertStockCashflow connection (Some transaction) sellEvent + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.StockSell + (DateOnly.FromDateTime executedAt.UtcDateTime) + proceeds + "stock_sell" + (sell.Id.ToString("D")) + None + transaction.Commit() StockSellWriteResult.StockSellCreated sell with error -> @@ -6620,6 +6737,23 @@ type FundRepository(connectionString: string) = addParameter removeCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore removeCommand.ExecuteNonQuery() |> ignore + let ledgerSource = + match normalized.EventType with + | "coupon" -> CashLedger.BondCoupon + | "maturity" -> CashLedger.BondMaturity + | _ -> CashLedger.BondSell + + insertCashLedgerEvent + connection + (Some transaction) + fundId + ledgerSource + record.EventDate + normalized.Amount + "bond_cashflow" + (record.Id.ToString("D")) + None + transaction.Commit() BondCashflowWriteResult.BondCashflowCreated record with error -> @@ -6825,6 +6959,17 @@ type FundRepository(connectionString: string) = addParameter positionCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore positionCommand.ExecuteNonQuery() |> ignore + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.BondSell + (DateOnly.FromDateTime executedAt.UtcDateTime) + proceeds + "bond_sell" + (record.Id.ToString("D")) + None + transaction.Commit() BondSellWriteResult.BondSellCreated record with error -> @@ -6914,6 +7059,56 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.GetCashLedger(fundId: Guid) : CashLedger.CashLedgerEvent list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT source_category, event_date, amount, reference_type, reference_id, note + FROM cash_ledger_events + WHERE fund_id = @fund_id + ORDER BY event_date, created_at, id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let events = ResizeArray<CashLedger.CashLedgerEvent>() + + while reader.Read() do + let source = + match CashLedger.parseSource (reader.GetString(0)) with + | Some value -> value + | None -> failwithf "unknown cash ledger source %s" (reader.GetString(0)) + + events.Add( + { + CashLedger.Source = source + CashLedger.EventDate = reader.GetFieldValue<DateOnly>(1) + CashLedger.Amount = reader.GetDecimal(2) + CashLedger.ReferenceType = reader.GetString(3) + CashLedger.ReferenceId = reader.GetString(4) + CashLedger.Note = readStringOption reader 5 + } + ) + + events |> Seq.toList + + /// Opening cash (the fund's initial capital) plus every ledger movement must equal the + /// fund's actual cash (available plus reserved held against pending orders). The + /// difference is what the reconciliation endpoint exposes and tests assert is zero. + member this.GetCashReconciliation(fundId: Guid) : Result<CashLedger.CashReconciliation, string> = + match this.GetFund fundId with + | None -> Error "fund was not found" + | Some fund -> + let events = this.GetCashLedger fundId + let ledgerBalance = fund.AvailableCash + fund.ReservedCash + Ok(CashLedger.reconcile fund.InitialCash ledgerBalance events) + member _.CreateRedemptionOrder(idempotencyKey: string, fundId: Guid, command: RedemptionCommand) : RedemptionWriteResult = if String.IsNullOrWhiteSpace idempotencyKey then RedemptionWriteResult.RedemptionInvalid "idempotency key cannot be empty" @@ -7325,6 +7520,18 @@ type FundRepository(connectionString: string) = confirmCommand.ExecuteNonQuery() |> ignore insertRedemptionConfirmIdempotency connection (Some transaction) idempotencyKey "" orderId fundId + + insertCashLedgerEvent + connection + (Some transaction) + fundId + CashLedger.Redemption + tradeDate + computation.Proceeds + "redemption" + (orderId.ToString("D")) + None + transaction.Commit() match findRedemptionOrder connection None orderId with @@ -7673,6 +7880,22 @@ type FundRepository(connectionString: string) = addParameter idempotencyCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore idempotencyCommand.ExecuteNonQuery() |> ignore + let ledgerSource = + match findOrderIdempotencyKeyByOrderId connection (Some transaction) orderId with + | Some key -> CashLedger.subscriptionSourceForKey key + | None -> CashLedger.Subscription + + insertCashLedgerEvent + connection + (Some transaction) + fundId + ledgerSource + quote.NavDate + -(computation.InvestedCash + order.FeeAmount) + "subscription_settle" + (orderId.ToString("D")) + None + transaction.Commit() match findOrder connection None orderId with diff --git a/src/FundLab.Domain/CashLedger.fs b/src/FundLab.Domain/CashLedger.fs new file mode 100644 index 0000000..d9d8b32 --- /dev/null +++ b/src/FundLab.Domain/CashLedger.fs @@ -0,0 +1,118 @@ +namespace FundLab.Domain + +open System + +/// Cash-ledger event sourcing and reconciliation for a fund. The ledger records every +/// movement of the fund's cash (available plus reserved) with a source category so the +/// statement can be grouped and checked against the actual cash balance. Amounts are +/// signed: positive is an inflow, negative an outflow. +module CashLedger = + /// Source category of a cash movement. The set is the vocabulary surfaced by the + /// reconciliation API/UI; not every category is reachable until the corresponding + /// operation actually moves cash. + type CashEventSource = + | Subscription + | Redemption + | Dividend + | Sip + | StockBuy + | StockSell + | BondBuy + | BondSell + | BondCoupon + | BondMaturity + | CapitalDeposit + + let sourceText (source: CashEventSource) : string = + match source with + | Subscription -> "subscription" + | Redemption -> "redemption" + | Dividend -> "dividend" + | Sip -> "sip" + | StockBuy -> "stock_buy" + | StockSell -> "stock_sell" + | BondBuy -> "bond_buy" + | BondSell -> "bond_sell" + | BondCoupon -> "bond_coupon" + | BondMaturity -> "bond_maturity" + | CapitalDeposit -> "capital_deposit" + + let parseSource (text: string) : CashEventSource option = + match (if isNull text then "" else text.Trim().ToLowerInvariant()) with + | "subscription" -> Some Subscription + | "redemption" -> Some Redemption + | "dividend" -> Some Dividend + | "sip" -> Some Sip + | "stock_buy" -> Some StockBuy + | "stock_sell" -> Some StockSell + | "bond_buy" -> Some BondBuy + | "bond_sell" -> Some BondSell + | "bond_coupon" -> Some BondCoupon + | "bond_maturity" -> Some BondMaturity + | "capital_deposit" -> Some CapitalDeposit + | _ -> None + + /// A scheduled/automated contribution is tagged as SIP; any other subscription key + /// is a manual subscription. The key prefix is the only origin signal carried by the + /// order pipeline, and it is set by the callers that own the schedule. + let subscriptionSourceForKey (key: string) : CashEventSource = + let text = if isNull key then "" else key + + if text.StartsWith("sip:", StringComparison.Ordinal) + || text.StartsWith("investment-plan:", StringComparison.Ordinal) then + Sip + else + Subscription + + type CashLedgerEvent = + { + Source: CashEventSource + EventDate: DateOnly + Amount: decimal + ReferenceType: string + ReferenceId: string + Note: string option + } + + type SourceTotal = + { + Source: CashEventSource + NetAmount: decimal + EventCount: int + } + + type CashReconciliation = + { + OpeningCash: decimal + NetInflow: decimal + ClosingCash: decimal + LedgerBalance: decimal + Difference: decimal + Sources: SourceTotal list + } + + /// Opening cash plus the net of every ledger event must equal the fund's actual cash + /// balance. `Difference` is zero exactly when the ledger fully explains the balance. + let reconcile (openingCash: decimal) (ledgerBalance: decimal) (events: CashLedgerEvent list) : CashReconciliation = + let netInflow = events |> List.sumBy (fun event -> event.Amount) + let closingCash = openingCash + netInflow + + let sources = + events + |> List.groupBy (fun event -> event.Source) + |> List.map (fun (source, group) -> + { + Source = source + NetAmount = group |> List.sumBy (fun event -> event.Amount) + EventCount = group.Length + }) + |> List.sortBy (fun total -> sourceText total.Source) + + { + OpeningCash = openingCash + NetInflow = netInflow + ClosingCash = closingCash + LedgerBalance = ledgerBalance + Difference = closingCash - ledgerBalance + Sources = sources + } diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 6d6a8ac..7c6db3f 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -14,6 +14,7 @@ <Compile Include="InvestmentPlan.fs" /> <Compile Include="Rebalance.fs" /> <Compile Include="Dividend.fs" /> + <Compile Include="CashLedger.fs" /> <Compile Include="BondRules.fs" /> <Compile Include="StockRules.fs" /> <Compile Include="Performance.fs" /> diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 56422ed..232608f 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -568,6 +568,35 @@ type RawFundValuation = positions: RawValuationPosition array } +type RawCashLedgerEvent = + { + source: string + eventDate: string + amount: string + referenceType: string + referenceId: string + note: obj + } + +type RawCashLedgerSource = + { + source: string + netAmount: string + eventCount: int + } + +type RawCashReconciliation = + { + fundId: string + openingCash: string + netInflow: string + closingCash: string + ledgerBalance: string + difference: string + sources: RawCashLedgerSource array + events: RawCashLedgerEvent array + } + type RawMarketRefreshTarget = { instrumentCode: string @@ -1024,6 +1053,34 @@ type FundValuationView = positions: ValuationPositionView list } +type CashLedgerEventView = + { + source: string + eventDate: string + amount: string + referenceType: string + referenceId: string + note: string option + } + +type CashLedgerSourceView = + { + source: string + netAmount: string + eventCount: int + } + +type CashReconciliationView = + { + openingCash: string + netInflow: string + closingCash: string + ledgerBalance: string + difference: string + sources: CashLedgerSourceView list + events: CashLedgerEventView list + } + type BondTradeAttempt = { idempotencyKey: string @@ -1113,6 +1170,9 @@ module Api = [<Import("getFundValuation", "./src/api.js")>] let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative + [<Import("getCashReconciliation", "./src/api.js")>] + let getCashReconciliation (token: string) (fundId: string) : JS.Promise<RawCashReconciliation> = jsNative + [<Import("refreshFundMarketData", "./src/api.js")>] let refreshFundMarketData (token: string) (fundId: string) : JS.Promise<RawMarketRefresh> = jsNative @@ -1495,6 +1555,34 @@ module Api = positions = raw.positions |> Array.toList |> List.map decodeValuationPosition } + let decodeCashLedgerEvent (raw: RawCashLedgerEvent) : CashLedgerEventView = + { + source = raw.source + eventDate = raw.eventDate + amount = raw.amount + referenceType = raw.referenceType + referenceId = raw.referenceId + note = Option.ofObj raw.note |> Option.map string + } + + let decodeCashLedgerSource (raw: RawCashLedgerSource) : CashLedgerSourceView = + { + source = raw.source + netAmount = raw.netAmount + eventCount = raw.eventCount + } + + let decodeCashReconciliation (raw: RawCashReconciliation) : CashReconciliationView = + { + openingCash = raw.openingCash + netInflow = raw.netInflow + closingCash = raw.closingCash + ledgerBalance = raw.ledgerBalance + difference = raw.difference + sources = raw.sources |> Array.toList |> List.map decodeCashLedgerSource + events = raw.events |> Array.toList |> List.map decodeCashLedgerEvent + } + type Model = { token: string @@ -1633,6 +1721,11 @@ type Model = valuationInFlight: bool valuation: FundValuationView option valuationMessage: string option + cashLedgerReadSeq: int + cashLedgerInFlight: bool + cashReconciliation: CashReconciliationView option + cashLedgerMessage: string option + cashLedgerFilter: string marketRefreshSeq: int marketRefreshInFlight: bool marketRefreshMessage: string option @@ -1782,6 +1875,10 @@ type Msg = | ValuationReadRequested | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation | ValuationReadFailed of requestId: int * fundId: string * message: string + | CashLedgerReadRequested + | CashLedgerReadCompleted of requestId: int * fundId: string * reconciliation: RawCashReconciliation + | CashLedgerReadFailed of requestId: int * fundId: string * message: string + | CashLedgerFilterChanged of string | MarketRefreshRequested | MarketRefreshCompleted of requestId: int * fundId: string * response: RawMarketRefresh | MarketRefreshFailed of requestId: int * fundId: string * message: string @@ -1968,6 +2065,11 @@ let init () = valuationInFlight = false valuation = None valuationMessage = None + cashLedgerReadSeq = 0 + cashLedgerInFlight = false + cashReconciliation = None + cashLedgerMessage = None + cashLedgerFilter = "" marketRefreshSeq = 0 marketRefreshInFlight = false marketRefreshMessage = None @@ -2260,6 +2362,13 @@ let private refreshMarketDataCommand token fundId requestId = (fun response -> MarketRefreshCompleted(requestId, fundId, response)) (fun error -> MarketRefreshFailed(requestId, fundId, errorText error)) +let private readCashLedgerCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getCashReconciliation token fundId) + () + (fun reconciliation -> CashLedgerReadCompleted(requestId, fundId, reconciliation)) + (fun error -> CashLedgerReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -2982,7 +3091,7 @@ let update message model = capitalNote = Some(sprintf "追加成功 + %s(现金余额已刷新)" amount) error = None }, - Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested ] + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested; Cmd.ofMsg CashLedgerReadRequested ] else model, Cmd.none | CapitalDepositFailed (requestId, message) -> @@ -3822,7 +3931,7 @@ let update message model = stockSellQuantities = Map.remove code model.stockSellQuantities stockSellAttempts = Map.remove code model.stockSellAttempts }, - Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested ] + Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested; Cmd.ofMsg CashLedgerReadRequested ] else model, Cmd.none | StockSellFailed (requestId, fundId, code, message) -> @@ -4056,6 +4165,44 @@ let update message model = Cmd.none else model, Cmd.none + | CashLedgerReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.cashLedgerReadSeq + 1 + + { + model with + cashLedgerReadSeq = requestId + cashLedgerInFlight = true + cashLedgerMessage = None + }, + readCashLedgerCommand model.token fund.id requestId + | _ -> model, Cmd.none + | CashLedgerReadCompleted (requestId, fundId, reconciliation) -> + if requestId = model.cashLedgerReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + cashReconciliation = Some(Api.decodeCashReconciliation reconciliation) + cashLedgerInFlight = false + cashLedgerMessage = None + }, + Cmd.none + else + model, Cmd.none + | CashLedgerReadFailed (requestId, fundId, message) -> + if requestId = model.cashLedgerReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + cashReconciliation = None + cashLedgerInFlight = false + cashLedgerMessage = Some(sprintf "现金账不可用 — %s" message) + }, + Cmd.none + else + model, Cmd.none + | CashLedgerFilterChanged value -> { model with cashLedgerFilter = value }, Cmd.none | MarketRefreshRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> @@ -6246,6 +6393,95 @@ let private stocksPanel model dispatch = ] ] ] + Html.div [ + prop.className "fund-cash-ledger" + prop.children [ + Html.p [ prop.className "returns-subheading"; prop.text "现金账对账" ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.button [ + prop.className "secondary-action cash-ledger-refresh-action" + prop.disabled model.cashLedgerInFlight + prop.onClick (fun _ -> dispatch CashLedgerReadRequested) + prop.text ((if model.cashLedgerInFlight then "加载中..." else "刷新现金账"): string) + ] + Html.select [ + prop.className "cash-ledger-filter" + prop.value model.cashLedgerFilter + prop.onChange (fun (value: string) -> dispatch (CashLedgerFilterChanged value)) + prop.children ( + [ Html.option [ prop.value ""; prop.text "全部来源" ] ] + @ (match model.cashReconciliation with + | Some reconciliation -> + reconciliation.sources + |> List.map (fun source -> Html.option [ prop.value source.source; prop.text source.source ]) + | None -> []) + ) + ] + ] + ] + match model.cashReconciliation with + | Some reconciliation -> + let filteredEvents = + if model.cashLedgerFilter = "" then + reconciliation.events + else + reconciliation.events |> List.filter (fun event -> event.source = model.cashLedgerFilter) + + Html.div [ + prop.className "cash-ledger-content" + prop.children ( + [ Html.div [ + prop.className "metric-strip cash-ledger-metrics" + prop.children [ + bondQuoteMetric "期初现金" (Some reconciliation.openingCash) + bondQuoteMetric "净流入" (Some reconciliation.netInflow) + bondQuoteMetric "期末现金" (Some reconciliation.closingCash) + bondQuoteMetric "账实差异" (Some reconciliation.difference) + ] + ] + Html.div [ + prop.className "cash-ledger-sources" + prop.children ( + reconciliation.sources + |> List.map (fun source -> + Html.div [ + prop.className "cash-ledger-source-row" + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text source.source ] + Html.span [ prop.className "fund-detail-value"; prop.text source.netAmount ] + Html.span [ prop.className "fund-detail-value"; prop.text (sprintf "%d 笔" source.eventCount) ] + ] + ]) + ) + ] + Html.div [ + prop.className "cash-ledger-events" + prop.children ( + filteredEvents + |> List.map (fun event -> + Html.div [ + prop.className "cash-ledger-event-row" + prop.children [ + Html.span [ prop.className "fund-detail-value"; prop.text event.eventDate ] + Html.span [ prop.className "fund-detail-label"; prop.text event.source ] + Html.span [ prop.className "fund-detail-value"; prop.text event.amount ] + Html.span [ prop.className "fund-detail-value"; prop.text (event.note |> Option.defaultValue "—") ] + ] + ]) + ) + ] ] + ) + ] + | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示按来源分组的现金流水与账实对账;期末现金 = 期初 + 净流入,差异应为 0。" ] + Html.p [ + prop.className "cash-ledger-unavailable" + prop.hidden (model.cashLedgerMessage.IsNone) + prop.text (model.cashLedgerMessage |> Option.defaultValue "") + ] + ] + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 3136371..26336ea 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -211,6 +211,10 @@ export function getFundValuation(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token); } +export function getCashReconciliation(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/cash-reconciliation`, token); +} + export function refreshFundMarketData(token, fundId, asOfDate) { const suffix = asOfDate ? `?asOfDate=${encodeURIComponent(asOfDate)}` : ""; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/market-data/refresh${suffix}`, token, { |
