namespace FundLab.Domain open System module DividendPolicy = let cashMaximum = 999999999999999999.99m let unitNavMaximum = 99999999999999999999.99999999m type DividendMode = | Cash | Reinvest let modeText (mode: DividendMode) : string = match mode with | Cash -> "cash" | Reinvest -> "reinvest" let parseMode (text: string) : DividendMode option = match text with | null -> None | "cash" -> Some Cash | "reinvest" -> Some Reinvest | _ -> None /// Distribution per unit: positive, eight-decimal precision (same scale as unit NAV). let validateDps (dps: decimal) : Result = if dps <= 0m then Error "dividend per unit must be positive" elif Decimal.Round(dps, 8) <> dps then Error "dividend per unit exceeds supported precision" elif dps > unitNavMaximum then Error "dividend per unit exceeds database precision" else Ok () /// No look-ahead: a dividend dated in the future cannot be booked or confirmed today. let validateNavDate (dividendNavDate: DateOnly) (today: DateOnly) : Result = if dividendNavDate > today then Error "dividend nav date is in the future" else Ok () type CashDividendOutcome = { GrossCash: decimal } type ReinvestDividendOutcome = { GrossCash: decimal RequestedUnits: decimal NAV: decimal } /// Cash dividend credits truncate(units * dps, 2) to available cash; units and cost /// are untouched (already-realized income returns to the fund, exactly one credit). let computeCashPayout (heldUnits: decimal) (dps: decimal) : Result = if heldUnits <= 0m then Error "held units must be positive for a cash dividend" elif dps <= 0m then Error "dividend per unit must be positive" else let gross = Decimal.Truncate(heldUnits * dps * 100m) / 100m if gross <= 0m then Error "dividend proceeds round to zero" else Ok { GrossCash = gross } /// Reinvestment prices the distribution at the confirmation-date NAV (the ex-date NAV) /// and reuses the subscription math: units = truncate8(amount / NAV), with the booked /// invested cash = truncate2(units * NAV) — identical to a manual order with fee 0. let computeReinvest (heldUnits: decimal) (dps: decimal) (unitNav: decimal) : Result = if heldUnits <= 0m then Error "held units must be positive for a reinvestment" elif dps <= 0m then Error "dividend per unit must be positive" elif unitNav <= 0m then Error "unit nav must be positive" elif unitNav < 0.00000001m then Error "unit nav is below database precision" else let gross = Decimal.Truncate(heldUnits * dps * 100m) / 100m if gross <= 0m then Error "dividend proceeds round to zero" else let requestedUnits = Decimal.Truncate(gross / unitNav * 100000000m) / 100000000m if requestedUnits <= 0m then Error "reinvestment units round to zero" else let invested = Decimal.Truncate(requestedUnits * unitNav * 100m) / 100m if invested <= 0m then Error "reinvestment invested cash rounds to zero" else Ok { GrossCash = gross RequestedUnits = requestedUnits NAV = unitNav } /// Deterministic keys: one record per scheme; replays with the same key never credit /// cash or create duplicate reinvestment orders. let recordKey (recordId: Guid) : string = sprintf "dividend:%O" recordId let confirmKey (recordId: Guid) : string = sprintf "dividend-confirm:%O" recordId