summaryrefslogtreecommitdiff
path: root/src/FundLab.Domain/CashLedger.fs
blob: d9d8b32da4887e625c8787ec28b18802108bd9e9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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
        }