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
}
|