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 }