summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 11:08:05 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 11:08:05 +0800
commit9818daebaf6fb99124a3196f880b3b8a7998d3a6 (patch)
tree0b027703ff1ff164a89965723cfd9d5a3fee5081
parent8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff)
downloadfund-lab-9818daebaf6fb99124a3196f880b3b8a7998d3a6.tar.gz
Add cash ledger event-source breakdown and reconciliation API/UI (3d-31 B4c)
-rw-r--r--src/FundLab.Api/App.fs77
-rw-r--r--src/FundLab.Api/Persistence.fs223
-rw-r--r--src/FundLab.Domain/CashLedger.fs118
-rw-r--r--src/FundLab.Domain/FundLab.Domain.fsproj1
-rw-r--r--src/FundLab.Web/App.fs240
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--tests/FundLab.Api.Tests/CashReconciliationTests.fs254
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs81
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)