summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/CashLedger.fs
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 /src/FundLab.Domain/CashLedger.fs
parent8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff)
downloadfund-lab-9818daebaf6fb99124a3196f880b3b8a7998d3a6.tar.gz
Add cash ledger event-source breakdown and reconciliation API/UI (3d-31 B4c)
Diffstat (limited to 'src/FundLab.Domain/CashLedger.fs')
-rw-r--r--src/FundLab.Domain/CashLedger.fs118
1 files changed, 118 insertions, 0 deletions
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
+ }