diff --git a/io/out/GeneratedDate.html b/io/out/GeneratedDate.html
index 186a1775..a62f67e4 100644
--- a/io/out/GeneratedDate.html
+++ b/io/out/GeneratedDate.html
@@ -1 +1 @@
-
Generated: 2025-06-05 15:01:19 +01:00 using library version: 2.5.5
+Generated: 2026-04-28 23:25:09 +00:00 using library version: 2.5.5
diff --git a/src/FSharp.Finance.Personal.fsproj b/src/FSharp.Finance.Personal.fsproj
index 4e71d5e8..501e398e 100644
--- a/src/FSharp.Finance.Personal.fsproj
+++ b/src/FSharp.Finance.Personal.fsproj
@@ -24,6 +24,7 @@
+
diff --git a/src/Mortgage.fs b/src/Mortgage.fs
new file mode 100644
index 00000000..3bf43de4
--- /dev/null
+++ b/src/Mortgage.fs
@@ -0,0 +1,381 @@
+namespace FSharp.Finance.Personal
+
+open System
+
+/// functions for specialist mortgage product calculations: offset mortgages, equity release / lifetime mortgages, and shared ownership
+module Mortgage =
+
+ open Calculation
+ open DateDay
+
+ // -------------------------------------------------------------------------
+ // Offset mortgage
+ // -------------------------------------------------------------------------
+
+ /// parameters for an offset mortgage calculation
+ []
+ type OffsetMortgageParameters = {
+ /// the outstanding mortgage balance (in base currency units, e.g. cents / pence)
+ MortgageBalance: int64
+ /// the total balance held in linked savings / current accounts that offset the mortgage balance
+ LinkedSavingsBalance: int64
+ /// the annual interest rate on the mortgage
+ AnnualInterestRate: Percent
+ /// the remaining term of the mortgage in months
+ RemainingTermMonths: int
+ /// the monthly payment amount (if already known); if not provided, it is derived from the full mortgage balance
+ MonthlyPayment: int64 voption
+ }
+
+ /// result of an offset mortgage calculation
+ []
+ type OffsetMortgageResult = {
+ /// the net balance on which interest is charged (mortgage balance minus linked savings)
+ NetInterestBearingBalance: int64
+ /// the monthly interest saving due to the offset
+ MonthlyInterestSaving: int64
+ /// the monthly payment on the net (offset) balance
+ EffectiveMonthlyPayment: int64
+ /// estimated months saved off the remaining term if monthly payment is kept the same
+ EstimatedMonthsSaved: int
+ /// total interest payable over the remaining term on the net (offset) balance
+ TotalInterestOnNetBalance: int64
+ /// total interest that would have been payable on the full mortgage balance
+ TotalInterestOnFullBalance: int64
+ }
+
+ /// calculates the monthly payment for a capital-and-interest (annuity) loan
+ let private calculateMonthlyPayment (principalCents: int64) (annualRatePercent: Percent) (termMonths: int) : int64 =
+ let r = Percent.toDecimal annualRatePercent / 12m
+ if r = 0m then
+ if termMonths = 0 then 0L
+ else Cent.round (RoundWith MidpointRounding.AwayFromZero) (decimal principalCents / decimal termMonths)
+ else
+ let decimalPow n x = decimal (Math.Pow(double x, double n))
+ let factor = (1m + r) |> decimalPow termMonths
+ let payment = decimal principalCents * r * factor / (factor - 1m)
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) payment
+
+ /// estimates how many months a capital-and-interest loan with given balance, rate and payment will take to repay
+ let private estimateTermMonths (principalCents: int64) (annualRatePercent: Percent) (monthlyPaymentCents: int64) : int =
+ let r = Percent.toDecimal annualRatePercent / 12m
+ if r = 0m then
+ if monthlyPaymentCents = 0L then Int32.MaxValue
+ else int (Math.Ceiling(decimal principalCents / decimal monthlyPaymentCents))
+ else
+ let p = decimal principalCents
+ let m = decimal monthlyPaymentCents
+ if m <= p * r then
+ // payment does not cover interest – loan never repays
+ Int32.MaxValue
+ else
+ // n = -ln(1 - r*P/M) / ln(1+r)
+ let n = -Math.Log(double (1m - r * p / m)) / Math.Log(double (1m + r))
+ int (Math.Ceiling n)
+
+ /// calculates total interest paid on a capital-and-interest loan
+ let private totalInterest (principalCents: int64) (annualRatePercent: Percent) (termMonths: int) : int64 =
+ let payment = calculateMonthlyPayment principalCents annualRatePercent termMonths
+ let total = decimal payment * decimal termMonths
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) (total - decimal principalCents)
+ |> max 0L
+
+ /// calculates offset mortgage metrics, including net interest-bearing balance, payment savings and term reduction
+ let calculateOffsetMortgage (p: OffsetMortgageParameters) : OffsetMortgageResult =
+ let netBalance =
+ (p.MortgageBalance - p.LinkedSavingsBalance)
+ |> max 0L
+
+ let fullPayment =
+ match p.MonthlyPayment with
+ | ValueSome mp -> mp
+ | ValueNone -> calculateMonthlyPayment p.MortgageBalance p.AnnualInterestRate p.RemainingTermMonths
+
+ let netPayment = calculateMonthlyPayment netBalance p.AnnualInterestRate p.RemainingTermMonths
+
+ let monthlySaving =
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) (decimal fullPayment - decimal netPayment)
+ |> max 0L
+
+ let estimatedMonthsSaved =
+ if netBalance = 0L then
+ p.RemainingTermMonths
+ else
+ let monthsAtFullPayment = estimateTermMonths netBalance p.AnnualInterestRate fullPayment
+ p.RemainingTermMonths - monthsAtFullPayment |> max 0
+
+ let totalInterestNet = totalInterest netBalance p.AnnualInterestRate p.RemainingTermMonths
+ let totalInterestFull = totalInterest p.MortgageBalance p.AnnualInterestRate p.RemainingTermMonths
+
+ {
+ NetInterestBearingBalance = netBalance
+ MonthlyInterestSaving = monthlySaving
+ EffectiveMonthlyPayment = netPayment
+ EstimatedMonthsSaved = estimatedMonthsSaved
+ TotalInterestOnNetBalance = totalInterestNet
+ TotalInterestOnFullBalance = totalInterestFull
+ }
+
+ // -------------------------------------------------------------------------
+ // Equity release / lifetime mortgage
+ // -------------------------------------------------------------------------
+
+ /// a voluntary partial repayment made at a specific date
+ []
+ type PartialRepayment = {
+ /// the date on which the partial repayment is made
+ RepaymentDate: Date
+ /// the amount repaid (in base currency units, e.g. cents / pence)
+ RepaymentAmount: int64
+ }
+
+ /// parameters for an equity release / lifetime mortgage calculation
+ []
+ type EquityReleaseParameters = {
+ /// the initial advance (in base currency units)
+ InitialAdvance: int64
+ /// the date on which the advance is made
+ StartDate: Date
+ /// the annual roll-up (compound) interest rate
+ AnnualInterestRate: Percent
+ /// the projected end date for balance calculation (e.g. expected sale or death)
+ ProjectionDate: Date
+ /// any voluntary partial repayments made during the term
+ PartialRepayments: PartialRepayment array
+ /// the maximum property value at the start, used to model the no-negative-equity guarantee
+ PropertyValue: int64 voption
+ }
+
+ /// result of an equity release projection
+ []
+ type EquityReleaseResult = {
+ /// projected outstanding balance at the projection date (before NNEG cap)
+ ProjectedBalance: int64
+ /// total interest rolled up over the projection period
+ TotalRolledUpInterest: int64
+ /// projected balance after applying the no-negative-equity guarantee (capped at property value)
+ BalanceAfterNneg: int64
+ /// whether the no-negative-equity guarantee was triggered (i.e. projected balance exceeded property value)
+ NnegTriggered: bool
+ }
+
+ /// projects the balance of an equity release / lifetime mortgage at a future date,
+ /// accounting for daily compounding and any voluntary partial repayments
+ let calculateEquityRelease (p: EquityReleaseParameters) : EquityReleaseResult =
+ // materialise DateTime values once for efficient comparison
+ let projectionDateTime = p.ProjectionDate.ToDateTime()
+
+ // sort repayments by date, materialising DateTime values once
+ let sortedRepayments =
+ p.PartialRepayments
+ |> Array.map (fun r -> r.RepaymentDate.ToDateTime(), r)
+ |> Array.sortBy fst
+
+ // compound the balance from the start date to the projection date,
+ // applying any repayments on the relevant dates
+ let dailyRate =
+ Percent.toDecimal p.AnnualInterestRate / 365m
+
+ let rec compound (currentDate: Date) (balance: decimal) (remainingRepayments: (DateTime * PartialRepayment) list) =
+ let currentDateTime = currentDate.ToDateTime()
+ if currentDateTime >= projectionDateTime then
+ balance
+ else
+ // apply any repayments due today
+ let repaidToday, remaining =
+ remainingRepayments
+ |> List.partition (fun (repaymentDt, _) -> repaymentDt <= currentDateTime)
+ let repaymentAmount =
+ repaidToday |> List.sumBy (fun (_, r) -> decimal r.RepaymentAmount)
+ let balanceAfterRepayment = max 0m (balance - repaymentAmount)
+ // accrue one day of interest
+ let newBalance = balanceAfterRepayment * (1m + dailyRate)
+ compound (currentDate.AddDays 1) newBalance remaining
+
+ let projectedDecimal =
+ compound p.StartDate (decimal p.InitialAdvance) (sortedRepayments |> Array.toList)
+
+ let projectedBalance =
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) projectedDecimal
+
+ let totalInterest = projectedBalance - p.InitialAdvance |> max 0L
+
+ let nnegCap =
+ match p.PropertyValue with
+ | ValueSome pv -> pv
+ | ValueNone -> Int64.MaxValue * 1L
+
+ let balanceAfterNneg = min projectedBalance nnegCap
+ let nnegTriggered = projectedBalance > nnegCap
+
+ {
+ ProjectedBalance = projectedBalance
+ TotalRolledUpInterest = totalInterest
+ BalanceAfterNneg = balanceAfterNneg
+ NnegTriggered = nnegTriggered
+ }
+
+ // -------------------------------------------------------------------------
+ // Shared ownership
+ // -------------------------------------------------------------------------
+
+ /// a staircasing tranche: buying an additional share of the property
+ []
+ type StaircasingTranche = {
+ /// the date on which the additional tranche is purchased
+ PurchaseDate: Date
+ /// the share being purchased (e.g. 0.25 for 25%)
+ AdditionalShare: Percent
+ /// the property value at the time of purchasing the additional tranche
+ PropertyValueAtPurchase: int64
+ }
+
+ /// parameters for a shared ownership calculation
+ []
+ type SharedOwnershipParameters = {
+ /// the total property value at the start
+ PropertyValue: int64
+ /// the share initially owned by the borrower (e.g. 0.25 for 25%)
+ InitialShare: Percent
+ /// the mortgage balance (covering the owned share) in base currency units
+ MortgageBalance: int64
+ /// the annual mortgage interest rate
+ MortgageAnnualRate: Percent
+ /// the mortgage term in months
+ MortgageTermMonths: int
+ /// the annual rent charged on the unowned share (as a percentage of unowned share value)
+ AnnualRentRate: Percent
+ /// any planned staircasing tranches
+ StaircasingTranches: StaircasingTranche array
+ /// the evaluation horizon in months (used for total cost comparison)
+ HorizonMonths: int
+ }
+
+ /// the blended monthly outgoing for a shared ownership product
+ []
+ type SharedOwnershipMonthlyOutgoing = {
+ /// the monthly mortgage payment
+ MonthlyMortgagePayment: int64
+ /// the monthly rent on the unowned share
+ MonthlyRent: int64
+ /// the total blended monthly outgoing
+ TotalMonthlyOutgoing: int64
+ }
+
+ /// the result of a staircasing calculation
+ []
+ type StaircasingResult = {
+ /// the cost of purchasing the additional tranche
+ TrancheCost: int64
+ /// the owned share after purchasing the tranche
+ NewOwnedShare: Percent
+ /// the remaining unowned share
+ NewUnownedShare: Percent
+ /// the new monthly rent on the remaining unowned share (at the original rent rate)
+ NewMonthlyRent: int64
+ /// the new monthly mortgage payment if the tranche is financed
+ NewMonthlyMortgagePayment: int64
+ /// the new blended monthly outgoing
+ NewTotalMonthlyOutgoing: int64
+ }
+
+ /// the total-cost comparison result
+ []
+ type TotalCostComparison = {
+ /// total cost over the horizon as a shared ownership occupier (mortgage payments + rent)
+ TotalSharedOwnershipCost: int64
+ /// total cost over the horizon of an outright purchase (assumes mortgage payments only, no rent, same property value and rate)
+ TotalOutrightPurchaseCost: int64
+ /// the difference (positive means shared ownership is more expensive over the horizon)
+ CostDifference: int64
+ }
+
+ /// calculates the blended monthly outgoing for a shared ownership product
+ let calculateSharedOwnershipOutgoing (p: SharedOwnershipParameters) : SharedOwnershipMonthlyOutgoing =
+ let mortgagePayment =
+ calculateMonthlyPayment p.MortgageBalance p.MortgageAnnualRate p.MortgageTermMonths
+
+ let unownedShare =
+ let (Percent ownedShare) = p.InitialShare
+ (100m - ownedShare) / 100m
+
+ let annualRent =
+ decimal p.PropertyValue * unownedShare * Percent.toDecimal p.AnnualRentRate
+
+ let monthlyRent =
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) (annualRent / 12m)
+
+ {
+ MonthlyMortgagePayment = mortgagePayment
+ MonthlyRent = monthlyRent
+ TotalMonthlyOutgoing = mortgagePayment + monthlyRent
+ }
+
+ /// calculates the cost and impact of purchasing an additional tranche (staircasing)
+ let calculateStaircasing
+ (p: SharedOwnershipParameters)
+ (tranche: StaircasingTranche)
+ (existingMortgageBalance: int64)
+ (remainingMortgageTermMonths: int)
+ : StaircasingResult =
+ let (Percent additionalSharePct) = tranche.AdditionalShare
+ let trancheCost =
+ Cent.round
+ (RoundWith MidpointRounding.AwayFromZero)
+ (decimal tranche.PropertyValueAtPurchase * additionalSharePct / 100m)
+
+ let (Percent ownedSharePct) = p.InitialShare
+ let newOwnedSharePct = ownedSharePct + additionalSharePct |> min 100m
+ let newOwnedShare = Percent newOwnedSharePct
+ let newUnownedShare = Percent (100m - newOwnedSharePct)
+
+ // new monthly rent on the remaining unowned share
+ let newAnnualRent =
+ decimal tranche.PropertyValueAtPurchase
+ * ((100m - newOwnedSharePct) / 100m)
+ * Percent.toDecimal p.AnnualRentRate
+
+ let newMonthlyRent =
+ Cent.round (RoundWith MidpointRounding.AwayFromZero) (newAnnualRent / 12m)
+
+ // new mortgage = existing balance + tranche cost, financed over remaining term at same rate
+ let newMortgageBalance = existingMortgageBalance + trancheCost
+ let newMortgagePayment =
+ calculateMonthlyPayment newMortgageBalance p.MortgageAnnualRate remainingMortgageTermMonths
+
+ {
+ TrancheCost = trancheCost
+ NewOwnedShare = newOwnedShare
+ NewUnownedShare = newUnownedShare
+ NewMonthlyRent = newMonthlyRent
+ NewMonthlyMortgagePayment = newMortgagePayment
+ NewTotalMonthlyOutgoing = newMortgagePayment + newMonthlyRent
+ }
+
+ /// estimates the total cost of shared ownership vs. outright purchase over a given horizon
+ let calculateTotalCostComparison (p: SharedOwnershipParameters) : TotalCostComparison =
+ let outgoing = calculateSharedOwnershipOutgoing p
+
+ // shared ownership: mortgage payments + rent for the horizon
+ // (simplified: constant payment and rent throughout, ignoring staircasing)
+ let totalSharedOwnershipCost =
+ Cent.round
+ (RoundWith MidpointRounding.AwayFromZero)
+ (decimal (outgoing.TotalMonthlyOutgoing) * decimal p.HorizonMonths)
+
+ // outright purchase comparison: mortgage payment on full property value, same rate and term
+ let outrightPayment =
+ calculateMonthlyPayment p.PropertyValue p.MortgageAnnualRate p.MortgageTermMonths
+
+ let totalOutrightCost =
+ Cent.round
+ (RoundWith MidpointRounding.AwayFromZero)
+ (decimal outrightPayment * decimal p.HorizonMonths)
+
+ let costDifference = totalSharedOwnershipCost - totalOutrightCost
+
+ {
+ TotalSharedOwnershipCost = totalSharedOwnershipCost
+ TotalOutrightPurchaseCost = totalOutrightCost
+ CostDifference = costDifference
+ }
diff --git a/tests/FSharp.Finance.Personal.Tests.fsproj b/tests/FSharp.Finance.Personal.Tests.fsproj
index f7ddeeb6..bb34d6d3 100644
--- a/tests/FSharp.Finance.Personal.Tests.fsproj
+++ b/tests/FSharp.Finance.Personal.Tests.fsproj
@@ -26,6 +26,7 @@
+
diff --git a/tests/MortgageTests.fs b/tests/MortgageTests.fs
new file mode 100644
index 00000000..ddfa4522
--- /dev/null
+++ b/tests/MortgageTests.fs
@@ -0,0 +1,281 @@
+namespace FSharp.Finance.Personal.Tests
+
+open Xunit
+open FsUnit.Xunit
+
+open FSharp.Finance.Personal
+
+module MortgageTests =
+
+ open Calculation
+ open DateDay
+ open Mortgage
+
+ // -------------------------------------------------------------------------
+ // Offset mortgage tests
+ // -------------------------------------------------------------------------
+
+ []
+ let OffsetMortgageTest001 () =
+ // £200,000 mortgage, £50,000 in savings, 3% annual rate, 20-year term
+ // Net balance = £150,000; verify monthly saving is positive
+ let p: OffsetMortgageParameters = {
+ MortgageBalance = 200_000_00L
+ LinkedSavingsBalance = 50_000_00L
+ AnnualInterestRate = Percent 3m
+ RemainingTermMonths = 240
+ MonthlyPayment = ValueNone
+ }
+
+ let result = calculateOffsetMortgage p
+
+ // net balance should be £150,000
+ result.NetInterestBearingBalance |> should equal 150_000_00L
+
+ // full payment on £200,000 should be greater than net payment on £150,000
+ result.EffectiveMonthlyPayment < result.EffectiveMonthlyPayment + result.MonthlyInterestSaving
+ |> should equal true
+
+ // some months should be saved
+ result.EstimatedMonthsSaved |> should be (greaterThan 0)
+
+ // total interest on net balance should be less than on full balance
+ result.TotalInterestOnNetBalance < result.TotalInterestOnFullBalance
+ |> should equal true
+
+ []
+ let OffsetMortgageTest002 () =
+ // savings exceed mortgage balance – net balance should be zero
+ let p: OffsetMortgageParameters = {
+ MortgageBalance = 100_000_00L
+ LinkedSavingsBalance = 150_000_00L
+ AnnualInterestRate = Percent 2.5m
+ RemainingTermMonths = 120
+ MonthlyPayment = ValueNone
+ }
+
+ let result = calculateOffsetMortgage p
+
+ result.NetInterestBearingBalance |> should equal 0L
+ result.EstimatedMonthsSaved |> should equal 120
+
+ []
+ let OffsetMortgageTest003 () =
+ // no linked savings – net balance equals mortgage balance and saving is zero
+ let p: OffsetMortgageParameters = {
+ MortgageBalance = 150_000_00L
+ LinkedSavingsBalance = 0L
+ AnnualInterestRate = Percent 4m
+ RemainingTermMonths = 180
+ MonthlyPayment = ValueNone
+ }
+
+ let result = calculateOffsetMortgage p
+
+ result.NetInterestBearingBalance |> should equal 150_000_00L
+ result.MonthlyInterestSaving |> should equal 0L
+ result.EstimatedMonthsSaved |> should equal 0
+ (result.TotalInterestOnNetBalance = result.TotalInterestOnFullBalance) |> should equal true
+
+ []
+ let OffsetMortgageTest004 () =
+ // explicit monthly payment provided
+ let p: OffsetMortgageParameters = {
+ MortgageBalance = 200_000_00L
+ LinkedSavingsBalance = 30_000_00L
+ AnnualInterestRate = Percent 3.5m
+ RemainingTermMonths = 300
+ MonthlyPayment = ValueSome 100_000L // £1,000/month
+ }
+
+ let result = calculateOffsetMortgage p
+
+ result.NetInterestBearingBalance |> should equal 170_000_00L
+ // monthly saving should be positive since the full payment > net payment
+ result.MonthlyInterestSaving |> should be (greaterThanOrEqualTo 0L)
+
+ // -------------------------------------------------------------------------
+ // Equity release tests
+ // -------------------------------------------------------------------------
+
+ []
+ let EquityReleaseTest001 () =
+ // £100,000 advance at 5% annual rate over 10 years, no repayments
+ let p: EquityReleaseParameters = {
+ InitialAdvance = 100_000_00L
+ StartDate = Date(2020, 1, 1)
+ AnnualInterestRate = Percent 5m
+ ProjectionDate = Date(2030, 1, 1)
+ PartialRepayments = [||]
+ PropertyValue = ValueNone
+ }
+
+ let result = calculateEquityRelease p
+
+ // projected balance should be greater than initial advance
+ result.ProjectedBalance > p.InitialAdvance |> should equal true
+ // total rolled-up interest should equal the difference
+ (result.TotalRolledUpInterest = result.ProjectedBalance - p.InitialAdvance) |> should equal true
+ // NNEG not triggered (no property value set)
+ result.NnegTriggered |> should equal false
+
+ []
+ let EquityReleaseTest002 () =
+ // NNEG triggered: property value smaller than projected balance
+ let p: EquityReleaseParameters = {
+ InitialAdvance = 100_000_00L
+ StartDate = Date(2000, 1, 1)
+ AnnualInterestRate = Percent 7m
+ ProjectionDate = Date(2040, 1, 1)
+ PartialRepayments = [||]
+ PropertyValue = ValueSome 200_000_00L
+ }
+
+ let result = calculateEquityRelease p
+
+ // after 40 years at 7%, £100k becomes ~£1.5M – well above £200k property value
+ result.NnegTriggered |> should equal true
+ result.BalanceAfterNneg |> should equal 200_000_00L
+
+ []
+ let EquityReleaseTest003 () =
+ // partial repayment reduces projected balance
+ let withoutRepayment: EquityReleaseParameters = {
+ InitialAdvance = 100_000_00L
+ StartDate = Date(2020, 1, 1)
+ AnnualInterestRate = Percent 5m
+ ProjectionDate = Date(2030, 1, 1)
+ PartialRepayments = [||]
+ PropertyValue = ValueNone
+ }
+
+ let withRepayment: EquityReleaseParameters = {
+ withoutRepayment with
+ PartialRepayments = [|
+ { RepaymentDate = Date(2025, 1, 1); RepaymentAmount = 10_000_00L }
+ |]
+ }
+
+ let balanceWithout = (calculateEquityRelease withoutRepayment).ProjectedBalance
+ let balanceWith = (calculateEquityRelease withRepayment).ProjectedBalance
+
+ balanceWith < balanceWithout |> should equal true
+
+ []
+ let EquityReleaseTest004 () =
+ // start date equals projection date – balance should equal initial advance
+ let p: EquityReleaseParameters = {
+ InitialAdvance = 50_000_00L
+ StartDate = Date(2025, 6, 15)
+ AnnualInterestRate = Percent 4m
+ ProjectionDate = Date(2025, 6, 15)
+ PartialRepayments = [||]
+ PropertyValue = ValueNone
+ }
+
+ let result = calculateEquityRelease p
+
+ result.ProjectedBalance |> should equal p.InitialAdvance
+ result.TotalRolledUpInterest |> should equal 0L
+
+ // -------------------------------------------------------------------------
+ // Shared ownership tests
+ // -------------------------------------------------------------------------
+
+ []
+ let SharedOwnershipTest001 () =
+ // £200,000 property, 50% share, mortgage on £100,000 at 3%, 25-year term
+ // rent on 50% unowned at 2.5% annual rent rate
+ let p: SharedOwnershipParameters = {
+ PropertyValue = 200_000_00L
+ InitialShare = Percent 50m
+ MortgageBalance = 100_000_00L
+ MortgageAnnualRate = Percent 3m
+ MortgageTermMonths = 300
+ AnnualRentRate = Percent 2.5m
+ StaircasingTranches = [||]
+ HorizonMonths = 60
+ }
+
+ let outgoing = calculateSharedOwnershipOutgoing p
+
+ // mortgage payment on £100k at 3% over 25 years
+ outgoing.MonthlyMortgagePayment |> should be (greaterThan 0L)
+ // monthly rent = £200,000 * 50% * 2.5% / 12
+ let expectedRent = Cent.round (RoundWith System.MidpointRounding.AwayFromZero) (200_000_00m * 0.5m * 0.025m / 12m)
+ outgoing.MonthlyRent |> should equal expectedRent
+ // total = mortgage + rent
+ (outgoing.TotalMonthlyOutgoing = outgoing.MonthlyMortgagePayment + outgoing.MonthlyRent)
+ |> should equal true
+
+ []
+ let SharedOwnershipTest002 () =
+ // staircasing: buy an additional 25% tranche at a £210,000 property value
+ let p: SharedOwnershipParameters = {
+ PropertyValue = 200_000_00L
+ InitialShare = Percent 50m
+ MortgageBalance = 95_000_00L
+ MortgageAnnualRate = Percent 3m
+ MortgageTermMonths = 240
+ AnnualRentRate = Percent 2.5m
+ StaircasingTranches = [||]
+ HorizonMonths = 60
+ }
+
+ let tranche: StaircasingTranche = {
+ PurchaseDate = Date(2026, 6, 1)
+ AdditionalShare = Percent 25m
+ PropertyValueAtPurchase = 210_000_00L
+ }
+
+ let result = calculateStaircasing p tranche 95_000_00L 240
+
+ // tranche cost = £210,000 * 25% = £52,500
+ result.TrancheCost |> should equal 52_500_00L
+ // new owned share = 50% + 25% = 75%
+ (result.NewOwnedShare = Percent 75m) |> should equal true
+ // new unowned share = 25%
+ (result.NewUnownedShare = Percent 25m) |> should equal true
+ // new rent should be lower (only 25% unowned)
+ let originalOutgoing = calculateSharedOwnershipOutgoing p
+ result.NewMonthlyRent < originalOutgoing.MonthlyRent |> should equal true
+
+ []
+ let SharedOwnershipTest003 () =
+ // total cost comparison: shared ownership vs outright purchase
+ let p: SharedOwnershipParameters = {
+ PropertyValue = 200_000_00L
+ InitialShare = Percent 40m
+ MortgageBalance = 80_000_00L
+ MortgageAnnualRate = Percent 3.5m
+ MortgageTermMonths = 300
+ AnnualRentRate = Percent 2.75m
+ StaircasingTranches = [||]
+ HorizonMonths = 120
+ }
+
+ let result = calculateTotalCostComparison p
+
+ result.TotalSharedOwnershipCost |> should be (greaterThan 0L)
+ result.TotalOutrightPurchaseCost |> should be (greaterThan 0L)
+ (result.CostDifference = result.TotalSharedOwnershipCost - result.TotalOutrightPurchaseCost)
+ |> should equal true
+
+ []
+ let SharedOwnershipTest004 () =
+ // 100% ownership: no rent, mortgage payment only
+ let p: SharedOwnershipParameters = {
+ PropertyValue = 150_000_00L
+ InitialShare = Percent 100m
+ MortgageBalance = 150_000_00L
+ MortgageAnnualRate = Percent 3m
+ MortgageTermMonths = 180
+ AnnualRentRate = Percent 2.5m
+ StaircasingTranches = [||]
+ HorizonMonths = 60
+ }
+
+ let outgoing = calculateSharedOwnershipOutgoing p
+
+ outgoing.MonthlyRent |> should equal 0L
+ (outgoing.TotalMonthlyOutgoing = outgoing.MonthlyMortgagePayment) |> should equal true