diff options
| -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 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/CashReconciliationTests.fs | 254 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 81 |
9 files changed, 997 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, { diff --git a/tests/FundLab.Api.Tests/CashReconciliationTests.fs b/tests/FundLab.Api.Tests/CashReconciliationTests.fs new file mode 100644 index 0000000..59f6fd6 --- /dev/null +++ b/tests/FundLab.Api.Tests/CashReconciliationTests.fs @@ -0,0 +1,254 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Xunit +open FundLab.Api +open FundLab.Domain + +[<Collection("postgres")>] +type CashReconciliationTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + + let createFund (initialCash: decimal) = + let command = + { + Name = "现金对账测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "cash-recon-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "现金对账测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "cash-recon-test-hash") + code + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) = + let revision = sprintf "cash-recon-test/%O" (Guid.NewGuid()) + + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = DateTimeOffset.UtcNow.AddSeconds(-10.0) + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, sprintf "cash-recon-hash/%s" revision) + + let deposit fundId amount idempotencyKey = + match + repository().CreateCapitalDeposit( + idempotencyKey, + fundId, + { Amount = amount; Note = Some "追加" } + ) + with + | CapitalDepositWriteResult.CapitalDepositCreated _ + | CapitalDepositWriteResult.CapitalDepositReplayed _ -> () + | other -> failwithf "unexpected deposit result: %A" other + + let buyStockDebit fundId code quantity price idempotencyKey = + match + repository().CreateStockTrade( + idempotencyKey, + fundId, + { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price }, + debitAvailableCash = true + ) + with + | StockTradeWriteResult.StockTradeCreated _ + | StockTradeWriteResult.StockTradeReplayed _ -> () + | other -> failwithf "unexpected stock trade result: %A" other + + let sellStock fundId code quantity price idempotencyKey = + match + repository().CreateStockSell( + idempotencyKey, + fundId, + { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price; FeeAmount = 0m } + ) + with + | StockSellWriteResult.StockSellCreated _ + | StockSellWriteResult.StockSellReplayed _ -> () + | other -> failwithf "unexpected stock sell result: %A" other + + let buyBond fundId code quantity price = + match + repository().CreateBondTrade( + fixture.Key(sprintf "cash-recon-bond-%s" (Guid.NewGuid().ToString("N"))), + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = price + CleanPrice = price + AccruedInterest = 0m + ParValue = 100m + SettlementDate = today + CouponRate = None + ValueDate = None + MaturityDate = None + TradeDate = Some today + } + ) + with + | BondTradeWriteResult.BondTradeCreated _ -> () + | other -> failwithf "unexpected bond trade result: %A" other + + let recordBondCoupon fundId code amount idempotencyKey = + match + repository().RecordBondCashflow( + idempotencyKey, + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + EventType = "coupon" + EventDate = today + Quantity = 10m + Amount = amount + Note = None + } + ) + with + | BondCashflowWriteResult.BondCashflowCreated _ + | BondCashflowWriteResult.BondCashflowReplayed _ -> () + | other -> failwithf "unexpected bond cashflow result: %A" other + + let subscribe fundId code amount idempotencyKey = + insertQuoteOnDate code 1.0m today + + let order = + match + repository().CreateSubscriptionOrder( + idempotencyKey, + fundId, + { FundCode = code; Amount = amount; FeeAmount = 0m }, + today + ) + with + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> order + | other -> failwithf "unexpected subscription order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key(sprintf "cash-recon-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed _ -> () + | other -> failwithf "unexpected subscription confirm: %A" other + + let app () = App.createApplication (repository ()) + + let reconciliation fundId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/cash-reconciliation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let sourceEntry (body: string) (source: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("sources").EnumerateArray() + |> Seq.find (fun entry -> entry.GetProperty("source").GetString() = source) + |> fun entry -> entry.Clone() + + let eventCount (body: string) (source: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("events").EnumerateArray() + |> Seq.filter (fun entry -> entry.GetProperty("source").GetString() = source) + |> Seq.length + + [<Fact>] + member _.``cash reconciliation balances across every source category``() = + let fundId = createFund 10000.00m + deposit fundId 5000.00m (fixture.Key "cash-recon-deposit") + buyStockDebit fundId "600519" 100m 10.00m (fixture.Key "cash-recon-buy") + sellStock fundId "600519" 40m 10.00m (fixture.Key "cash-recon-sell") + buyBond fundId "110075" 10m 100.00m + recordBondCoupon fundId "110075" 50.00m (fixture.Key "cash-recon-coupon") + subscribe fundId (seedInstrument ()) 1000.00m (fixture.Key "cash-recon-subscribe") + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"openingCash\":\"10000.00\"", body) + Assert.Contains("\"netInflow\":\"3450.00\"", body) + Assert.Contains("\"closingCash\":\"13450.00\"", body) + Assert.Contains("\"ledgerBalance\":\"13450.00\"", body) + Assert.Contains("\"difference\":\"0.00\"", body) + + Assert.Equal("5000.00", (sourceEntry body "capital_deposit").GetProperty("netAmount").GetString()) + Assert.Equal("-1000.00", (sourceEntry body "stock_buy").GetProperty("netAmount").GetString()) + Assert.Equal("400.00", (sourceEntry body "stock_sell").GetProperty("netAmount").GetString()) + Assert.Equal("50.00", (sourceEntry body "bond_coupon").GetProperty("netAmount").GetString()) + Assert.Equal("-1000.00", (sourceEntry body "subscription").GetProperty("netAmount").GetString()) + + // every category reconciles to zero, i.e. no unexplained cash movement + Assert.Equal(0.00m, (repository().GetCashReconciliation fundId |> Result.defaultWith failwith).Difference) + + [<Fact>] + member _.``replayed operations do not double count ledger events``() = + let fundId = createFund 1000.00m + let depositKey = fixture.Key "cash-recon-idem-deposit" + let buyKey = fixture.Key "cash-recon-idem-buy" + + deposit fundId 500.00m depositKey + deposit fundId 500.00m depositKey + buyStockDebit fundId "600519" 100m 10.00m buyKey + buyStockDebit fundId "600519" 100m 10.00m buyKey + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"difference\":\"0.00\"", body) + Assert.Contains("\"ledgerBalance\":\"500.00\"", body) // 1000 + 500 - 1000 + + Assert.Equal(1, eventCount body "capital_deposit") + Assert.Equal(1, eventCount body "stock_buy") + + [<Fact>] + member _.``reconciliation for a missing fund answers 404``() = + let status, body = reconciliation (Guid.NewGuid()) + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index f37dce4..66cc2aa 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -43,6 +43,7 @@ <Compile Include="SipPlanStatusTests.fs" /> <Compile Include="RebalanceArchiveTests.fs" /> <Compile Include="RebalanceStockTests.fs" /> + <Compile Include="CashReconciliationTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index 63bcab2..c3d4551 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -880,3 +880,84 @@ module DividendPolicyTests = Assert.Equal("dividend:7c9e6679-7425-40de-944b-e07fc1f90ae7", DividendPolicy.recordKey recordId) Assert.Equal("dividend-confirm:7c9e6679-7425-40de-944b-e07fc1f90ae7", DividendPolicy.confirmKey recordId) + +module CashLedgerTests = + + open System + open Xunit + open FundLab.Domain + + let private event source amount : CashLedger.CashLedgerEvent = + { + Source = source + EventDate = DateOnly(2026, 9, 21) + Amount = amount + ReferenceType = "test" + ReferenceId = Guid.NewGuid().ToString("D") + Note = None + } + + [<Fact>] + let ``cash source text roundtrips through parse`` () = + let sources = + [ + CashLedger.Subscription + CashLedger.Redemption + CashLedger.Dividend + CashLedger.Sip + CashLedger.StockBuy + CashLedger.StockSell + CashLedger.BondBuy + CashLedger.BondSell + CashLedger.BondCoupon + CashLedger.BondMaturity + CashLedger.CapitalDeposit + ] + + for source in sources do + Assert.Equal(Some source, CashLedger.parseSource (CashLedger.sourceText source)) + + Assert.Equal(None, CashLedger.parseSource "unknown") + + [<Fact>] + let ``scheduled contribution keys are tagged as sip`` () = + Assert.Equal(CashLedger.Sip, CashLedger.subscriptionSourceForKey "sip:plan:2026-09-21") + Assert.Equal(CashLedger.Sip, CashLedger.subscriptionSourceForKey "investment-plan:abc:2026-09-21") + Assert.Equal(CashLedger.Subscription, CashLedger.subscriptionSourceForKey "manual-key") + Assert.Equal(CashLedger.Subscription, CashLedger.subscriptionSourceForKey null) + + [<Fact>] + let ``reconciliation groups sources and balances`` () = + let events = + [ + event CashLedger.CapitalDeposit 5000.00m + event CashLedger.StockBuy -1000.00m + event CashLedger.StockSell 400.00m + event CashLedger.StockSell 100.00m + ] + + let reconciliation = CashLedger.reconcile 10000.00m 14500.00m events + + Assert.Equal(10000.00m, reconciliation.OpeningCash) + Assert.Equal(4500.00m, reconciliation.NetInflow) + Assert.Equal(14500.00m, reconciliation.ClosingCash) + Assert.Equal(14500.00m, reconciliation.LedgerBalance) + Assert.Equal(0m, reconciliation.Difference) + + let bySource = + reconciliation.Sources |> List.map (fun total -> CashLedger.sourceText total.Source, total.NetAmount, total.EventCount) + + Assert.Equal<(string * decimal * int) list>( + [ ("capital_deposit", 5000.00m, 1); ("stock_buy", -1000.00m, 1); ("stock_sell", 500.00m, 2) ], + bySource + ) + + [<Fact>] + let ``reconciliation reports a non-zero difference`` () = + let reconciliation = CashLedger.reconcile 10000.00m 9000.00m [ event CashLedger.StockBuy -500.00m ] + Assert.Equal(9500.00m, reconciliation.ClosingCash) + Assert.Equal(500.00m, reconciliation.Difference) + + let empty = CashLedger.reconcile 100.00m 100.00m [] + Assert.Equal(0m, empty.Difference) + Assert.Empty(empty.Sources) |
