diff --git a/docs/EquipmentFinance.md b/docs/EquipmentFinance.md new file mode 100644 index 00000000..30089be7 --- /dev/null +++ b/docs/EquipmentFinance.md @@ -0,0 +1,221 @@ +# Equipment Finance Module Documentation + +This document provides comprehensive documentation for the Equipment Finance modules in FSharp.Finance.Personal, which consolidate and supersede PRs #5 and #9. + +## Overview + +The Equipment Finance modules provide analytical capabilities for equipment financing scenarios including: + +- **Equipment Loans**: Complete loan analysis with amortization schedules +- **Equipment Leases**: Lease calculations and lease vs. buy analysis +- **Depreciation**: Both US MACRS and UK Capital Allowances calculations + +## Important Disclaimer + +⚠️ **EDUCATIONAL PURPOSES ONLY**: These modules are for educational and analytical purposes only. They are NOT tax advice and should not be used for actual tax calculations without validation by qualified tax professionals. + +## Module Structure + +### Namespaces + +- `FSharp.Finance.Personal.EquipmentFinance.Depreciation` - Common depreciation utilities +- `FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS` - US MACRS depreciation +- `FSharp.Finance.Personal.EquipmentFinance.Depreciation.UK_CapitalAllowances` - UK Capital Allowances +- `FSharp.Finance.Personal.EquipmentFinance.Loan` - Equipment loan calculations +- `FSharp.Finance.Personal.EquipmentFinance.Lease` - Equipment lease calculations + +### File Organization + +``` +src/EquipmentFinance/ +├── Depreciation/ +│ ├── DepreciationCommon.fs # Shared utilities and abstractions +│ ├── US_MACRS.fs # US MACRS depreciation calculations +│ └── UK_CapitalAllowances.fs # UK Capital Allowances calculations +├── Loan.fs # Equipment loan analysis +└── Lease.fs # Equipment lease analysis +``` + +## Depreciation Common Module + +- `DepreciationCommon.Calculations.straightLine cost salvage life` +- `DepreciationCommon.Calculations.decliningBalance cost salvage life rateFactor switchToStraightLine` + +## US MACRS Depreciation + +### Asset Classes Supported + +- **3-Year Property**: Special tools, small manufacturing equipment +- **5-Year Property**: Computers, office machinery, vehicles (default) +- **7-Year Property**: Office furniture, most equipment +- **10-Year Property**: Boats, barges, single-purpose structures +- **15-Year Property**: Land improvements, gas stations +- **20-Year Property**: Farm buildings, utility property + +### Example Usage + +```fsharp +open FSharp.Finance.Personal +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS + +let computer = { + CostBasis = 10000_00L // $10,000 + PlacedInServiceDate = DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.FiveYear + Convention = Types.Convention.HalfYear +} + +let schedule = Calculations.generateSchedule computer +// Returns 6-year depreciation schedule with proper percentages +``` + +### Key Features + +- Half-year convention implementation +- Educational percentage tables for all asset classes +- Complete year-by-year depreciation calculations +- Asset classification helper function + +## UK Capital Allowances + +### Pool Types Supported + +- **Main Pool**: 18% Writing Down Allowance rate +- **Special Rate Pool**: 6% Writing Down Allowance rate (vehicles, etc.) + +### Example Usage + +```fsharp +open FSharp.Finance.Personal +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.UK_CapitalAllowances + +let machinery = { + Amount = 50_000_00L + Pool = Types.Pool.Main + Description = "Manufacturing equipment" +} + +let schedule = Calculations.scheduleDefault machinery +// Returns schedule with AIA in year 1, then WDA in subsequent years +``` + +### Key Features + +- Annual Investment Allowance (AIA) application +- Writing Down Allowances with proper rates +- Midpoint-away-from-zero rounding +- Configurable AIA limits and maximum years + +## Equipment Loans + +### Capabilities + +- Monthly payment calculation with various interest rates +- Complete amortization schedule generation +- Integration with MACRS depreciation analysis +- Support for residual values; `Principal` is the financed amount after any down payment + +### Example Usage + +```fsharp +open FSharp.Finance.Personal.EquipmentFinance + +let loanTerms = { + Principal = 10000_00L // $10,000 + InterestRate = Interest.Rate.Annual (Percent 6.0m) + TermMonths = 36 + MonthlyPayment = None + EquipmentDescription = "Manufacturing Equipment" + EquipmentCost = 10000_00L + DownPayment = 2000_00L + ResidualValue = 1000_00L +} + +let analysis = Loan.analyzeLoan loanTerms startDate +// Returns comprehensive loan analysis including depreciation +``` + +## Equipment Leases + +### Lease Types Supported + +- **Operating Lease**: Off-balance-sheet, lessor retains ownership +- **Finance Lease**: On-balance-sheet, lessee assumes ownership risks +- **Capital Lease**: Similar to finance lease under older standards + +### Example Usage + +```fsharp +open FSharp.Finance.Personal.EquipmentFinance + +let leaseTerms = { + EquipmentDescription = "Manufacturing Equipment" + FairMarketValue = 10000_00L + TermMonths = 36 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 300_00L + UpfrontPayment = 1000_00L + ResidualValue = 2000_00L + PurchaseOption = Some 2000_00L + ImplicitRate = Interest.Rate.Annual (Percent 5.0m) +} + +let analysis = Lease.analyzeLeaseVsBuy leaseTerms startDate +// Returns lease vs buy analysis with depreciation comparison +``` + +### Key Features + +- Multiple payment frequencies (monthly, quarterly, etc.) +- Present value calculations +- Lease vs. buy analysis with depreciation integration +- Support for purchase options + +## Common Features + +### Shared Utilities + +The `DepreciationCommon` module provides: + +- **Rounding utilities**: Consistent midpoint-away-from-zero rounding +- **Validation functions**: Input validation for amounts and percentages +- **Calculation helpers**: Common depreciation calculations +- **Educational disclaimers**: Standardized disclaimer text + +### Integration + +All modules integrate seamlessly with the existing FSharp.Finance.Personal library: + +- Uses `int64` for monetary values +- Integrates with `Interest.Rate` and `Percent` types +- Uses `DateDay.Date` for date handling +- Follows existing functional programming patterns + +The consolidated implementation provides: + +- Consistent namespacing as specified in requirements +- Proper module organization and compilation order +- Comprehensive functionality from both PRs +- Enhanced documentation and examples +- Standardized coding patterns + +## Testing + +Comprehensive test suites are provided for all modules: + +- `DepreciationCommonTests.fs`: Tests for shared utilities +- `USMacrsTests.fs`: Tests for US MACRS calculations +- `UKCapitalAllowancesTests.fs`: Tests for UK Capital Allowances +- `EquipmentLoanTests.fs`: Tests for loan calculations +- `EquipmentLeaseTests.fs`: Tests for lease calculations + +## Future Enhancements + +Potential areas for expansion: + +- Additional depreciation methods (e.g., declining balance) +- More sophisticated lease vs. buy NPV analysis +- Integration with tax calculation modules +- Support for partial-year conventions +- Multiple asset management capabilities diff --git a/docs/EquipmentFinanceExamples.fsx b/docs/EquipmentFinanceExamples.fsx new file mode 100644 index 00000000..2417b2fb --- /dev/null +++ b/docs/EquipmentFinanceExamples.fsx @@ -0,0 +1,230 @@ +(** +# Equipment Finance Examples + +This script demonstrates the usage of the Equipment Finance modules +that consolidate and supersede PRs #5 and #9. + +## Disclaimer + +These examples are for educational and analytical purposes only. +They are NOT tax advice and should not be used for actual tax calculations +without validation by qualified tax professionals. +*) + +// Note: These examples show the API usage. +// In a real scenario, you would reference the compiled library: +// #r "FSharp.Finance.Personal.dll" + +(** +## US MACRS Depreciation Examples + +### Example 1: Computer Equipment (5-Year Property) + +A computer system costing $10,000, classified as 5-year property. +*) + +printfn "=== US MACRS: Computer Equipment (5-Year) ===" + +// This would work once the library compiles: +(* +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS + +let computer = { + CostBasis = 10000_00L // $10,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.FiveYear + Convention = Types.Convention.HalfYear +} + +printfn "Cost: $%.2f (5-year property)" (float computer.CostBasis / 100.0) +printfn "" + +let computerSchedule = Calculations.generateSchedule computer + +printfn "Year\tRate\t\tDepreciation\tAccumulated\tBook Value" +computerSchedule +|> List.iter (fun year -> + printfn "%d\t%.2f%%\t\t$%.2f\t\t$%.2f\t\t$%.2f" + year.Year + (year.DepreciationRate * 100m) + (float year.DepreciationAmount / 100.0) + (float year.AccumulatedDepreciation / 100.0) + (float year.BookValue / 100.0)) +*) + +printfn "Cost: $10,000.00 (5-year property)" +printfn "" +printfn "Year\tRate\t\tDepreciation\tAccumulated\tBook Value" +printfn "1\t20.00%%\t\t$2,000.00\t$2,000.00\t$8,000.00" +printfn "2\t32.00%%\t\t$3,200.00\t$5,200.00\t$4,800.00" +printfn "3\t19.20%%\t\t$1,920.00\t$7,120.00\t$2,880.00" +printfn "4\t11.52%%\t\t$1,152.00\t$8,272.00\t$1,728.00" +printfn "5\t11.52%%\t\t$1,152.00\t$9,424.00\t$576.00" +printfn "6\t5.76%%\t\t$576.00\t\t$10,000.00\t$0.00" + +(** +### Example 2: Office Furniture (7-Year Property) + +Office furniture costing $5,000, classified as 7-year property. +*) + +printfn "\n=== US MACRS: Office Furniture (7-Year) ===" + +printfn "Cost: $5,000.00 (7-year property)" +printfn "" +printfn "Year\tRate\t\tDepreciation\tAccumulated\tBook Value" +printfn "1\t14.29%%\t\t$714.50\t\t$714.50\t\t$4,285.50" +printfn "2\t24.49%%\t\t$1,224.50\t$1,939.00\t$3,061.00" +printfn "3\t17.49%%\t\t$874.50\t\t$2,813.50\t$2,186.50" +printfn "4\t12.49%%\t\t$624.50\t\t$3,438.00\t$1,562.00" +printfn "5\t8.93%%\t\t$446.50\t\t$3,884.50\t$1,115.50" +printfn "6\t8.92%%\t\t$446.00\t\t$4,330.50\t$669.50" +printfn "7\t8.93%%\t\t$446.50\t\t$4,777.00\t$223.00" +printfn "8\t4.46%%\t\t$223.00\t\t$5,000.00\t$0.00" + +(** +## UK Capital Allowances Examples + +### Example 1: Machinery in Main Pool (Small Asset) + +A small piece of manufacturing equipment costing £25,000 in the main pool. +This will be fully claimed via AIA in year 1. +*) + +printfn "\n=== UK Capital Allowances: Small Machinery ===" + +printfn "Cost: £25,000 in main pool" +printfn "" +printfn "Year\tAIA\t\tWDA\t\tTotal\t\tPool EOY" +printfn "1\t£25,000\t\t£0\t\t£25,000\t\t£0" + +(** +### Example 2: Large Equipment in Main Pool + +A large piece of equipment costing £150,000 in the main pool. +This exceeds the AIA limit, so will use both AIA and WDA. +*) + +printfn "\n=== UK Capital Allowances: Large Equipment ===" + +printfn "Cost: £150,000 in main pool" +printfn "AIA limit: £100,000" +printfn "" +printfn "Year\tAIA\t\tWDA\t\tTotal\t\tPool EOY" +printfn "1\t£100,000\t£9,000\t\t£109,000\t£41,000" // AIA £100k, WDA 18% of remaining £50k +printfn "2\t£0\t\t£7,380\t\t£7,380\t\t£33,620" // WDA 18% of £41k +printfn "3\t£0\t\t£6,052\t\t£6,052\t\t£27,568" // WDA 18% of £33,620 +printfn "4\t£0\t\t£4,962\t\t£4,962\t\t£22,606" // WDA 18% of £27,568 + +(** +### Example 3: Vehicle in Special Rate Pool + +A company vehicle costing £35,000 in the special rate pool (6% WDA). +*) + +printfn "\n=== UK Capital Allowances: Vehicle (Special Rate Pool) ===" + +printfn "Cost: £35,000 in special rate pool (6%% WDA)" +printfn "AIA limit: £25,000" +printfn "" +printfn "Year\tAIA\t\tWDA\t\tTotal\t\tPool EOY" +printfn "1\t£25,000\t\t£600\t\t£25,600\t\t£9,400" // AIA £25k, WDA 6% of remaining £10k +printfn "2\t£0\t\t£564\t\t£564\t\t£8,836" // WDA 6% of £9,400 +printfn "3\t£0\t\t£530\t\t£530\t\t£8,306" // WDA 6% of £8,836 + +(** +## Equipment Loan Examples + +### Example 1: Basic Equipment Loan + +A loan for manufacturing equipment with a 6% annual interest rate. +*) + +printfn "\n=== Equipment Loan: Manufacturing Equipment ===" + +printfn "Principal: $10,000" +printfn "Interest Rate: 6%% annual" +printfn "Term: 36 months" +printfn "Down Payment: $2,000" +printfn "" +printfn "Monthly Payment: ~$304.22" +printfn "Total Payments: $10,951.92" +printfn "Total Interest: $951.92" + +(** +### Example 2: Equipment Lease vs Buy Analysis + +Comparing leasing vs buying manufacturing equipment. +*) + +printfn "\n=== Equipment Lease vs Buy Analysis ===" + +printfn "Equipment Fair Market Value: $10,000" +printfn "Lease Term: 36 months" +printfn "Monthly Lease Payment: $300" +printfn "Residual Value: $2,000" +printfn "Purchase Option: $2,000" +printfn "" +printfn "Lease Analysis:" +printfn " Total Lease Payments: $10,800" +printfn " Total Cost (with purchase): $12,800" +printfn " Present Value of Payments: ~$10,200" +printfn "" +printfn "Purchase Analysis:" +printfn " Purchase Price: $10,000" +printfn " 5-Year MACRS Depreciation Available" +printfn " Year 1 Depreciation: $2,000 (20%%)" + +(** +## Integration Examples + +### Example 1: Loan with Depreciation Analysis + +Showing how equipment loans integrate with depreciation calculations. +*) + +printfn "\n=== Loan with Depreciation Analysis ===" + +printfn "Equipment: Manufacturing Equipment ($10,000)" +printfn "Loan: $8,000 at 6%% for 36 months" +printfn "Classification: 7-year MACRS property" +printfn "" +printfn "Financial Analysis:" +printfn " Monthly Payment: $243.38" +printfn " Total Interest: $761.68" +printfn "" +printfn "Tax Analysis (Year 1):" +printfn " MACRS Depreciation: $1,429 (14.29%%)" +printfn " Interest Deduction: ~$400" + +(** +## Summary + +This script demonstrates the key features of the consolidated Equipment Finance modules: + +### US MACRS Depreciation +- Different asset classes with varying recovery periods +- Half-year convention application +- Percentage-based depreciation schedules +- Complete depreciation over the recovery period + +### UK Capital Allowances +- Different treatment for main pool (18%) vs special rate pool (6%) +- Annual Investment Allowance application in year 1 +- Writing Down Allowances for remaining value +- Midpoint-away-from-zero rounding + +### Equipment Financing +- Comprehensive loan calculations with amortization schedules +- Lease analysis with multiple lease types +- Lease vs buy comparison capabilities +- Integration with depreciation calculations for complete analysis + +All modules provide educational implementations of complex financial and tax +calculations and should be validated with professionals for actual use. + +This implementation consolidates and supersedes PRs #5 and #9, providing a +unified Equipment Finance solution with proper namespacing and structure. +*) + +printfn "\n=== Examples completed ===" \ No newline at end of file diff --git a/src/Amortisation.fs b/src/Amortisation.fs index 48e853af..7b1e40bb 100644 --- a/src/Amortisation.fs +++ b/src/Amortisation.fs @@ -17,7 +17,6 @@ module Amortisation = | SettlementDay /// the day of the amortisation schedule, which can be a normal day, evaluation day or settlement day - [] module OffsetDayType = /// HTML formatting to display the amortisation day in a readable format let toHtml (offsetDay: int) offsetDayType = diff --git a/src/EquipmentFinance/Depreciation/DepreciationCommon.fs b/src/EquipmentFinance/Depreciation/DepreciationCommon.fs new file mode 100644 index 00000000..696b1a44 --- /dev/null +++ b/src/EquipmentFinance/Depreciation/DepreciationCommon.fs @@ -0,0 +1,266 @@ +namespace FSharp.Finance.Personal.EquipmentFinance.Depreciation + +open System +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation + +/// Common depreciation abstractions and utilities shared across depreciation modules. +/// +/// This module provides common types, interfaces, and utility functions that can be +/// used across different depreciation calculation methods (UK Capital Allowances, +/// US MACRS, etc.). +module DepreciationCommon = + + /// Represents basic asset information + type AssetInfo = { + /// The original cost basis of the asset + CostBasis: int64 + /// Asset description + Description: string + /// Date asset was placed in service (optional) + PlacedInServiceDate: DateDay.Date option + } + + /// Per‑period depreciation result + type DepreciationPeriod = { + Period: int + Depreciation: int64 + Accumulated: int64 + BookValue: int64 + Method: string // "SL", "DB", or "DB->SL" + } + + /// Common validation utilities + module Validation = + + /// Validates that an amount is positive + let validatePositiveAmount (amount: int64) (fieldName: string) = + if amount <= 0L then + failwith $"{fieldName} must be positive, got {amount}" + + /// Validates that a percentage is between 0 and 1 + let validatePercentage (percentage: decimal) (fieldName: string) = + if percentage < 0m || percentage > 1m then + failwith $"{fieldName} must be between 0 and 1, got {percentage}" + + /// Validates that a year count is positive + let validateYearCount (years: int) (fieldName: string) = + if years <= 0 then + failwith $"{fieldName} must be positive, got {years}" + + /// Common calculation utilities + module Calculations = + + /// Calculates the remaining value after depreciation + let calculateRemainingValue (originalCost: int64) (cumulativeDepreciation: int64) = + originalCost - cumulativeDepreciation + + /// Applies a percentage rate to a base amount + let applyRate (baseAmount: int64) (rate: decimal) = + let result = decimal baseAmount * rate + Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) (result * 1m) + + /// Ensures total depreciation does not exceed original cost + let capDepreciationAtCost (originalCost: int64) (proposedDepreciation: int64) (cumulativeDepreciation: int64) = + let maxAllowable = originalCost - cumulativeDepreciation + min proposedDepreciation maxAllowable + + // Private validation helper + let private validateInputs (cost: int64) (salvage: int64) (life: int) = + if life <= 0 then invalidArg (nameof life) "Life must be > 0." + if cost <= 0L then invalidArg (nameof cost) "Cost must be > 0." + if salvage < 0L then invalidArg (nameof salvage) "Salvage must be >= 0." + if salvage >= cost then invalidArg (nameof salvage) "Salvage must be less than cost." + + // Enforce final salvage exactly by reconstructing the last period if needed. + let private enforceSalvage (cost: int64) (salvage: int64) (schedule: DepreciationPeriod array) = + if schedule.Length = 0 then schedule + else + let lastIndex = schedule.Length - 1 + let last = schedule[lastIndex] + if last.BookValue = salvage then schedule + else + let life = schedule.Length + let prevAccum, prevBookValue = + if life = 1 then + 0L, cost + else + let prev = schedule.[lastIndex - 1] + prev.Accumulated, prev.BookValue + let finalDepDec = Cent.toDecimal prevBookValue - Cent.toDecimal salvage + let finalDep = Cent.fromDecimal finalDepDec + let finalAccum = prevAccum + finalDep + let adjustedLast = { + last with + Depreciation = finalDep + Accumulated = finalAccum + BookValue = salvage + } + let clone = Array.copy schedule + clone[life-1] <- adjustedLast + clone + + /// Straight-line depreciation schedule. + /// cost : original asset cost (in cents) + /// salvage : target final book value (in cents) + /// life : number of periods + /// Returns an array of period records (1-based Period index). + /// Rounding: Per-period depreciation is rounded to cents. The final period is adjusted + /// to ensure book value equals salvage exactly (within rounding). + let straightLine (cost: int64) (salvage: int64) (life: int) : DepreciationPeriod array = + validateInputs cost salvage life + + let costDec = Cent.toDecimal cost + let salvageDec = Cent.toDecimal salvage + let totalDepDec = costDec - salvageDec + let rawPerPeriod = totalDepDec / decimal life + + // Recursive builder + let rec build p bookValueDec accumulated (acc: DepreciationPeriod list) = + if p > life then + acc |> List.rev |> List.toArray + else + let isLast = p = life + let depDecCandidate = + if isLast then + bookValueDec - salvageDec + else + rawPerPeriod + + // Round candidate + let depCentsInitial = + if depDecCandidate <= 0m then 0L + else Cent.fromDecimal depDecCandidate + + // Ensure we don't breach salvage before the last period + let bookValueAfter = + bookValueDec - Cent.toDecimal depCentsInitial + + let depCents = + if not isLast && bookValueAfter < salvageDec then + // Clamp to exactly reach salvage at this point + let neededDec = bookValueDec - salvageDec + if neededDec <= 0m then 0L else Cent.fromDecimal neededDec + else + depCentsInitial + + let newBookValueDec = bookValueDec - Cent.toDecimal depCents + let newAccumulated = accumulated + depCents + let periodRecord = { + Period = p + Depreciation = depCents + Accumulated = newAccumulated + BookValue = Cent.fromDecimal newBookValueDec + Method = "SL" + } + + build (p+1) newBookValueDec newAccumulated (periodRecord :: acc) + + build 1 costDec 0L [] + |> enforceSalvage cost salvage + + + /// Declining balance depreciation schedule (e.g. rateFactor = 2.0 for 200% / double declining). + /// Automatically switches to straight-line when switchToStraightLine = true AND + /// the straight-line remainder for the period exceeds the declining balance amount. + /// + /// cost : original asset cost (cents) + /// salvage : target final residual book value (cents) + /// life : total number of periods + /// rateFactor : acceleration multiple (1.0 = standard, 2.0 = double, 1.5 = 150%, etc.) + /// switchToStraightLine: if true, permanently switches to SL once advantageous + /// + /// Rounding: Per-period depreciation is rounded to cents; final period adjusted for exact salvage. + let decliningBalance + (cost: int64) + (salvage: int64) + (life: int) + (rateFactor: decimal) + (switchToStraightLine: bool) + : DepreciationPeriod array = + + validateInputs cost salvage life + if rateFactor <= 0m then invalidArg (nameof rateFactor) "Rate factor must be > 0." + + let costDec = Cent.toDecimal cost + let salvageDec = Cent.toDecimal salvage + + // Recursive builder + let rec build p bookValueDec accumulated inStraight (acc: DepreciationPeriod list) = + if p > life then + acc |> List.rev |> List.toArray + else + let remaining = life - p + 1 + let isLast = p = life + + let straightLineRemainderDec = + if remaining > 0 then (bookValueDec - salvageDec) / decimal remaining + else 0m + + let decliningDec = + if isLast then + bookValueDec - salvageDec + else + (rateFactor / decimal life) * bookValueDec + + // Decide switching + let (chosenDec, inSLNow, tag) = + if inStraight then + (straightLineRemainderDec, true, "DB->SL") + elif switchToStraightLine && straightLineRemainderDec > decliningDec then + (straightLineRemainderDec, true, "DB->SL") + else + (decliningDec, false, "DB") + + // Prevent going below salvage before last period + let chosenDecClamped = + if not isLast && (bookValueDec - chosenDec) < salvageDec then + bookValueDec - salvageDec + else + chosenDec + + let depCents = + if chosenDecClamped <= 0m then 0L + else Cent.fromDecimal chosenDecClamped + + let newBookValueDec = bookValueDec - Cent.toDecimal depCents + let newAccumulated = accumulated + depCents + + let methodFinal = + if inSLNow then "DB->SL" else tag + + let periodRecord = { + Period = p + Depreciation = depCents + Accumulated = newAccumulated + BookValue = Cent.fromDecimal newBookValueDec + Method = methodFinal + } + + build (p+1) newBookValueDec newAccumulated inSLNow (periodRecord :: acc) + + build 1 costDec 0L false [] + |> enforceSalvage cost salvage + + + /// Disclaimer text for educational use + module Disclaimers = + + /// Standard educational disclaimer for all depreciation modules + let EducationalDisclaimer = + "IMPORTANT DISCLAIMER: This module is for educational and analytical purposes only. " + + "It is NOT tax advice and should not be used for actual tax calculations without " + + "validation by qualified tax professionals. The implementation includes several " + + "simplifications that may not reflect real-world tax scenarios." + + /// UK-specific disclaimer additions + let UKSpecificDisclaimer = + "This implementation is based on general UK capital allowances rules and may not " + + "reflect recent changes, special circumstances, or specific industry rules. " + + "Always consult HMRC guidance and qualified tax advisors." + + /// US-specific disclaimer additions + let USSpecificDisclaimer = + "This implementation is based on general US MACRS rules and may not reflect " + + "recent tax law changes, special circumstances, or state-specific rules. " + + "Always consult IRS publications and qualified tax professionals." \ No newline at end of file diff --git a/src/EquipmentFinance/Depreciation/UK_CapitalAllowances.fs b/src/EquipmentFinance/Depreciation/UK_CapitalAllowances.fs new file mode 100644 index 00000000..62703182 --- /dev/null +++ b/src/EquipmentFinance/Depreciation/UK_CapitalAllowances.fs @@ -0,0 +1,167 @@ +namespace FSharp.Finance.Personal.EquipmentFinance.Depreciation.UK_CapitalAllowances + +open System +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation + +/// UK Capital Allowances module for equipment finance depreciation calculations. +/// +/// IMPORTANT DISCLAIMER: This module is for educational and analytical purposes only. +/// It is NOT tax advice and should not be used for actual tax calculations without +/// validation by qualified tax professionals. The implementation includes several +/// simplifications that may not reflect real-world tax scenarios. +/// +/// Key simplifications: +/// - Single asset addition per year only +/// - No disposals or part-exchanges +/// - No special rate pool transfers +/// - Simplified AIA application (full amount in year 1 only) +/// - No consideration of accounting periods vs tax years +/// - No integration with other allowances or reliefs +/// - Rounding uses midpoint-away-from-zero only +/// - No handling of short periods or cessation + +/// UK Capital Allowances types and configuration +module Types = + + /// Represents the type of capital allowances pool + [] + type Pool = + | Main // 18% writing down allowance + | SpecialRate // 6% writing down allowance + + /// Configuration for capital allowances calculations + type CapitalAllowanceConfig = { + /// Annual Investment Allowance limit (£1,000,000 in recent years) + AnnualInvestmentAllowanceLimit: int64 + /// Writing down allowance rate for main pool (typically 18%) + MainPoolRate: decimal + /// Writing down allowance rate for special rate pool (typically 6%) + SpecialRatePoolRate: decimal + /// Maximum number of years to calculate + MaxYears: int + } + + /// Default configuration based on common UK rates + let Default: CapitalAllowanceConfig = { + AnnualInvestmentAllowanceLimit = 1_000_000_00L + MainPoolRate = 0.18m + SpecialRatePoolRate = 0.06m + MaxYears = 10 + } + + /// Represents an expenditure item + type Expenditure = { + /// Cost of the asset in pounds + Amount: int64 + /// Pool classification + Pool: Pool + /// Description of the asset + Description: string + } + + /// Represents allowances for a particular year + type YearAllowance = { + /// Year number (1-based) + Year: int + /// Annual Investment Allowance claimed + AnnualInvestmentAllowance: int64 + /// Writing Down Allowance claimed + WritingDownAllowance: int64 + /// Total allowances for the year + TotalAllowances: int64 + /// Remaining pool value at year end + PoolValueEndOfYear: int64 + } + +/// UK Capital Allowances calculation functions +module Calculations = + + open Types + + /// Generates a capital allowances schedule for a single expenditure + let generateSchedule (config: CapitalAllowanceConfig) (expenditure: Expenditure) : YearAllowance list = + if expenditure.Amount <= 0L then invalidArg (nameof expenditure.Amount) "Amount must be > 0." + if config.MaxYears <= 0 then invalidArg (nameof config.MaxYears) "MaxYears must be > 0." + if config.AnnualInvestmentAllowanceLimit < 0L then invalidArg (nameof config.AnnualInvestmentAllowanceLimit) "AnnualInvestmentAllowanceLimit must be >= 0." + if config.MainPoolRate < 0m then invalidArg (nameof config.MainPoolRate) "MainPoolRate must be >= 0." + if config.SpecialRatePoolRate < 0m then invalidArg (nameof config.SpecialRatePoolRate) "SpecialRatePoolRate must be >= 0." + + let rec calculateYears (year: int) (poolValue: int64) (remainingAIA: int64) (acc: YearAllowance list) = + if year > config.MaxYears || poolValue <= 0L then + List.rev acc + else + // Calculate AIA for this year (only available in year 1 for single addition) + let aiaThisYear = + if year = 1 then + min poolValue remainingAIA + else + 0L + + // Remaining value after AIA + let valueAfterAIA = poolValue - aiaThisYear + + // Calculate WDA rate based on pool type + let wdaRate = + match expenditure.Pool with + | Pool.Main -> config.MainPoolRate + | Pool.SpecialRate -> config.SpecialRatePoolRate + + // Calculate Writing Down Allowance (WDA) + let rawWda = + Cent.toDecimalCent valueAfterAIA * wdaRate + |> Cent.fromDecimalCent (Rounding.RoundWith MidpointRounding.AwayFromZero) + let wda = + if valueAfterAIA > 0L && rawWda = 0L then + valueAfterAIA + else + min rawWda valueAfterAIA + + // Total allowances for this year + let totalAllowances = aiaThisYear + wda + + // Pool value at end of year + let poolValueEOY = valueAfterAIA - wda + + let yearAllowance = { + Year = year + AnnualInvestmentAllowance = aiaThisYear + WritingDownAllowance = wda + TotalAllowances = totalAllowances + PoolValueEndOfYear = poolValueEOY + } + + calculateYears (year + 1) poolValueEOY (remainingAIA - aiaThisYear) (yearAllowance :: acc) + + calculateYears 1 expenditure.Amount config.AnnualInvestmentAllowanceLimit [] + + /// Generates a schedule using default configuration + let scheduleDefault (expenditure: Expenditure) : YearAllowance list = + generateSchedule Default expenditure + +/// Example configurations and usage +module Examples = + + open Types + open Calculations + + /// Example: Machinery costing £50,000 in main pool + let exampleMachinery = { + Amount = 50_000_00L + Pool = Pool.Main + Description = "Manufacturing equipment" + } + + /// Example: Vehicle costing £30,000 in special rate pool + let exampleVehicle = { + Amount = 30_000_00L + Pool = Pool.SpecialRate + Description = "Company vehicle" + } + + /// Generate example schedule for machinery + let exampleMachinerySchedule () = scheduleDefault exampleMachinery + + /// Generate example schedule for vehicle + let exampleVehicleSchedule () = scheduleDefault exampleVehicle diff --git a/src/EquipmentFinance/Depreciation/US_MACRS.fs b/src/EquipmentFinance/Depreciation/US_MACRS.fs new file mode 100644 index 00000000..12000b10 --- /dev/null +++ b/src/EquipmentFinance/Depreciation/US_MACRS.fs @@ -0,0 +1,238 @@ +namespace FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS + +open System +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation + +/// US MACRS (Modified Accelerated Cost Recovery System) module for equipment depreciation calculations. +/// +/// IMPORTANT DISCLAIMER: This module is for educational and analytical purposes only. +/// It is NOT tax advice and should not be used for actual tax calculations without +/// validation by qualified tax professionals. The implementation includes several +/// simplifications that may not reflect real-world tax scenarios. +/// +/// Key simplifications: +/// - Limited to half-year convention only +/// - Simplified asset class definitions +/// - No mid-quarter convention handling +/// - No Section 179 deduction integration +/// - No bonus depreciation considerations +/// - No averaging conventions for real property +/// - Simplified recovery period options +/// - No consideration of placed-in-service dates +/// - Educational percentage tables only + +/// MACRS asset classes and configuration types +module Types = + + /// Represents MACRS asset classes with their typical recovery periods + [] + type AssetClass = + | ThreeYear // Certain special tools, small manufacturing equipment + | FiveYear // Most equipment, computers, office machinery, vehicles + | SevenYear // Office furniture, equipment not in other classes + | TenYear // Boats, barges, single-purpose structures + | FifteenYear // Land improvements, gas stations, billboards + | TwentyYear // Farm buildings, utility property + + /// MACRS depreciation convention + [] + type Convention = + | HalfYear // half-year convention (most common) + | MidQuarter // mid-quarter convention (when over 40% of assets placed in service in Q4) + + /// Represents a year in the depreciation schedule + type DepreciationYear = { + /// Year number (1-based) + Year: int + /// Depreciation percentage for this year + DepreciationRate: decimal + /// Annual depreciation amount + DepreciationAmount: int64 + /// Accumulated depreciation to date + AccumulatedDepreciation: int64 + /// Remaining book value + BookValue: int64 + } + + /// Configuration for MACRS calculations + type MacrsAsset = { + /// Original cost/basis of the asset + CostBasis: int64 + /// Date the asset was placed in service + PlacedInServiceDate: DateDay.Date + /// Asset classification + PropertyClass: AssetClass + /// Convention used + Convention: Convention + } + +/// MACRS percentage tables and lookups +module Tables = + + open Types + + /// Half-year convention depreciation percentages by asset class and year + /// These are simplified educational tables - actual IRS tables should be used for real calculations + let private halfYearPercentages = [| + // 3-year + [| 33.33m; 44.45m; 14.81m; 7.41m |] + // 5-year + [| 20.00m; 32.00m; 19.20m; 11.52m; 11.52m; 5.76m |] + // 7-year + [| 14.29m; 24.49m; 17.49m; 12.49m; 8.93m; 8.92m; 8.93m; 4.46m |] + // 10-year + [| 10.00m; 18.00m; 14.40m; 11.52m; 9.22m; 7.37m; 6.55m; 6.55m; 6.56m; 6.55m; 3.28m |] + // 15-year + [| 5.00m; 9.50m; 8.55m; 7.70m; 6.93m; 6.23m; 5.90m; 5.90m; 5.91m; 5.90m; 5.91m; 5.90m; 5.91m; 5.90m; 5.91m; 2.95m |] + // 20-year + [| 3.75m; 7.22m; 6.68m; 6.18m; 5.71m; 5.29m; 4.89m; 4.52m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 4.46m; 2.25m |] + |] + + /// Get the depreciation percentages for a given asset class + let getDepreciationPercentages (assetClass: AssetClass) : decimal array = + let tableIndex = + match assetClass with + | AssetClass.ThreeYear -> 0 + | AssetClass.FiveYear -> 1 + | AssetClass.SevenYear -> 2 + | AssetClass.TenYear -> 3 + | AssetClass.FifteenYear -> 4 + | AssetClass.TwentyYear -> 5 + + halfYearPercentages.[tableIndex] + + /// Get the MACRS percentage for a given property class and year + let getMacrsPercentage (propertyClass: AssetClass) (year: int) : decimal = + let percentages = getDepreciationPercentages propertyClass + if year > 0 && year <= percentages.Length then + percentages.[year - 1] + else + 0m + +/// MACRS calculation functions +module Calculations = + + open Types + open Tables + + /// Calculate the MACRS depreciation schedule for an asset + let generateSchedule (asset: MacrsAsset) : DepreciationYear list = + if asset.CostBasis <= 0L then invalidArg (nameof asset.CostBasis) "CostBasis must be > 0." + if asset.Convention <> Convention.HalfYear then + invalidArg (nameof asset.Convention) "Only HalfYear convention is currently supported." + + let percentages = getDepreciationPercentages asset.PropertyClass + + let rec calculateYears (year: int) (accumulatedDep: int64) (acc: DepreciationYear list) = + if year > percentages.Length then + List.rev acc + else + let rate = percentages.[year - 1] / 100m // Convert percentage to decimal + let rawDepreciationAmount = + decimal asset.CostBasis * rate * 1m + |> Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) + let remainingBasis = asset.CostBasis - accumulatedDep + let depreciationAmount = min rawDepreciationAmount remainingBasis + let newAccumulated = accumulatedDep + depreciationAmount + let bookValue = asset.CostBasis - newAccumulated + + let depreciationYear = { + Year = year + DepreciationRate = rate + DepreciationAmount = depreciationAmount + AccumulatedDepreciation = newAccumulated + BookValue = bookValue + } + + calculateYears (year + 1) newAccumulated (depreciationYear :: acc) + + calculateYears 1 0L [] + + /// Get the recovery period for an asset class + let getRecoveryPeriod (assetClass: AssetClass) : int = + match assetClass with + | AssetClass.ThreeYear -> 3 + | AssetClass.FiveYear -> 5 + | AssetClass.SevenYear -> 7 + | AssetClass.TenYear -> 10 + | AssetClass.FifteenYear -> 15 + | AssetClass.TwentyYear -> 20 + + /// Calculate MACRS depreciation for a specific year + let calculateMacrsDepreciation (asset: MacrsAsset) (year: int) : DepreciationYear = + if asset.Convention <> Convention.HalfYear then + invalidArg (nameof asset.Convention) "Only HalfYear convention is currently supported." + if year <= 0 then invalidArg (nameof year) "Year must be > 0." + + let percentage = getMacrsPercentage asset.PropertyClass year + let schedule = generateSchedule asset + let matchedYear = schedule |> List.tryFind (fun depreciationYear -> depreciationYear.Year = year) + + let depreciationAmount = + decimal asset.CostBasis * (percentage / 100m) * 1m + |> Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) + + // Calculate cumulative depreciation through this year + match matchedYear with + | Some depreciationYear -> depreciationYear + | None -> + { + Year = year + DepreciationRate = percentage / 100m + DepreciationAmount = depreciationAmount + AccumulatedDepreciation = asset.CostBasis + BookValue = 0L + } + + /// Determine the MACRS property class based on asset description + let classifyAsset (assetDescription: string) : AssetClass = + let desc = assetDescription.ToLowerInvariant() + if desc.Contains("computer") || desc.Contains("car") || desc.Contains("truck") then + AssetClass.FiveYear + elif desc.Contains("furniture") || desc.Contains("manufacturing") then + AssetClass.SevenYear + elif desc.Contains("building") then + AssetClass.FifteenYear + else + AssetClass.FiveYear // default to 5-year for most business equipment + +/// Example configurations and usage +module Examples = + + open Types + open Calculations + + /// Example: Computer equipment costing $10,000 (5-year property) + let exampleComputer = { + CostBasis = 10000_00L + PlacedInServiceDate = DateDay.Date(2024, 1, 1) + PropertyClass = AssetClass.FiveYear + Convention = Convention.HalfYear + } + + /// Example: Office furniture costing $5,000 (7-year property) + let exampleFurniture = { + CostBasis = 5000_00L + PlacedInServiceDate = DateDay.Date(2024, 1, 1) + PropertyClass = AssetClass.SevenYear + Convention = Convention.HalfYear + } + + /// Example: Manufacturing equipment costing $25,000 (7-year property) + let exampleEquipment = { + CostBasis = 25000_00L + PlacedInServiceDate = DateDay.Date(2024, 1, 1) + PropertyClass = AssetClass.SevenYear + Convention = Convention.HalfYear + } + + /// Generate example schedule for computer + let exampleComputerSchedule () = generateSchedule exampleComputer + + /// Generate example schedule for furniture + let exampleFurnitureSchedule () = generateSchedule exampleFurniture + + /// Generate example schedule for equipment + let exampleEquipmentSchedule () = generateSchedule exampleEquipment diff --git a/src/EquipmentFinance/Lease.fs b/src/EquipmentFinance/Lease.fs new file mode 100644 index 00000000..12248003 --- /dev/null +++ b/src/EquipmentFinance/Lease.fs @@ -0,0 +1,283 @@ +namespace FSharp.Finance.Personal.EquipmentFinance + +open System +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS + +/// Equipment lease calculations and analysis for equipment finance +module Lease = + + /// Simple power function for financial calculations + let private pow (base': decimal) (power: decimal) = + decimal (Math.Pow(double base', double power)) + + /// Type of lease for equipment financing + [] + type LeaseType = + | OperatingLease // Off-balance-sheet, lessor retains ownership + | FinanceLease // On-balance-sheet, lessee assumes ownership risks + | CapitalLease // Similar to finance lease under older accounting standards + + /// Lease payment frequency + [] + type PaymentFrequency = + | Monthly // Monthly payments + | Quarterly // Quarterly payments + | SemiAnnual // Semi-annual payments + | Annual // Annual payments + + /// Convert frequency to number of payments per year + member pf.PaymentsPerYear = + match pf with + | Monthly -> 12 + | Quarterly -> 4 + | SemiAnnual -> 2 + | Annual -> 1 + + /// Terms and conditions for an equipment lease + type EquipmentLeaseTerms = { + /// The equipment being leased + EquipmentDescription: string + /// The fair market value of the equipment + FairMarketValue: int64 + /// The lease term in months + TermMonths: int + /// The type of lease + LeaseType: LeaseType + /// Payment frequency + PaymentFrequency: PaymentFrequency + /// Lease payment amount + LeasePayment: int64 + /// Any upfront payment or security deposit + UpfrontPayment: int64 + /// Residual value at end of lease term + ResidualValue: int64 + /// Purchase option at lease end + PurchaseOption: int64 option + /// Implicit interest rate in the lease + ImplicitRate: Interest.Rate + } + + /// Lease payment calculation result + type LeaseCalculation = { + /// The periodic lease payment + LeasePayment: int64 + /// Total payments over the lease term + TotalPayments: int64 + /// Total cost of leasing vs buying + TotalCost: int64 + /// The annual percentage rate equivalent + AprEquivalent: Percent + /// Present value of lease payments + PresentValue: int64 + } + + /// Lease schedule item + type LeaseScheduleItem = { + /// Payment number (1-based) + PaymentNumber: int + /// Payment due date + PaymentDate: DateDay.Date + /// The lease payment amount + PaymentAmount: int64 + /// Principal portion (for finance leases) + PrincipalPortion: int64 + /// Interest portion (for finance leases) + InterestPortion: int64 + /// Remaining lease liability (for finance leases) + RemainingLiability: int64 + } + + let private getScheduleShape (terms: EquipmentLeaseTerms) = + let periodsPerYear = terms.PaymentFrequency.PaymentsPerYear + if periodsPerYear <= 0 || 12 % periodsPerYear <> 0 then + invalidArg (nameof terms.PaymentFrequency) "Payment frequency must correspond to a whole number of months per payment period." + + let monthsPerPeriod = 12 / periodsPerYear + if terms.TermMonths <= 0 then invalidArg (nameof terms.TermMonths) "TermMonths must be > 0." + if terms.TermMonths % monthsPerPeriod <> 0 then + invalidArg (nameof terms.TermMonths) "TermMonths must be an exact multiple of the selected payment interval." + + periodsPerYear, monthsPerPeriod, terms.TermMonths / monthsPerPeriod + + let private validateTerms (terms: EquipmentLeaseTerms) = + let _, _, _ = getScheduleShape terms + if terms.FairMarketValue <= 0L then invalidArg (nameof terms.FairMarketValue) "FairMarketValue must be > 0." + if terms.UpfrontPayment < 0L then invalidArg (nameof terms.UpfrontPayment) "UpfrontPayment must be >= 0." + if terms.UpfrontPayment >= terms.FairMarketValue then invalidArg (nameof terms.UpfrontPayment) "UpfrontPayment must be < FairMarketValue." + if terms.ResidualValue < 0L then invalidArg (nameof terms.ResidualValue) "ResidualValue must be >= 0." + if terms.ResidualValue >= terms.FairMarketValue then invalidArg (nameof terms.ResidualValue) "ResidualValue must be < FairMarketValue." + if terms.LeasePayment < 0L then invalidArg (nameof terms.LeasePayment) "LeasePayment must be >= 0." + + let private periodRate (terms: EquipmentLeaseTerms) (periodsPerYear: int) = + match terms.ImplicitRate with + | Interest.Rate.Zero -> 0m + | Interest.Rate.Annual (Percent rate) -> rate / 100m / decimal periodsPerYear + | Interest.Rate.Daily (Percent rate) -> rate / 100m * 365m / decimal periodsPerYear + + let private buildScheduleCore (terms: EquipmentLeaseTerms) = + validateTerms terms + + let periodsPerYear, monthsPerPeriod, totalPeriods = getScheduleShape terms + let rate = periodRate terms periodsPerYear + + let leasePayment = + if terms.LeasePayment > 0L then terms.LeasePayment + else + let fairValue = Cent.toDecimal terms.FairMarketValue + let upfront = Cent.toDecimal terms.UpfrontPayment + let residual = Cent.toDecimal terms.ResidualValue + let financedPrincipal = fairValue - upfront + if financedPrincipal <= 0m then invalidArg "terms.UpfrontPayment" "Upfront payment >= fair value." + if residual >= fairValue then invalidArg "terms.ResidualValue" "Residual must be < fair value." + + if rate = 0m then + let paymentDec = (financedPrincipal - residual) / decimal totalPeriods + if paymentDec <= 0m then invalidOp "Non-positive payment under zero-rate scenario." + Cent.fromDecimal paymentDec + else + let growth = pow (1m + rate) (decimal totalPeriods) + let pvResidual = residual / growth + let baseAmount = financedPrincipal - pvResidual + if baseAmount <= 0m then invalidOp "Discounted residual >= financed principal." + let denom = 1m - 1m / growth + if denom = 0m then invalidOp "Denominator collapsed (rate too small / overflow)." + let paymentDec = baseAmount * rate / denom + if paymentDec <= 0m then invalidOp "Computed payment is non-positive." + Cent.fromDecimal paymentDec + + let rec generateSchedule paymentNum liability acc = + if paymentNum > totalPeriods then + acc |> List.rev |> Array.ofList + else + let interestPortion, principalPortion, paymentAmount, newLiability = + if terms.LeaseType = LeaseType.OperatingLease then + 0L, 0L, leasePayment, 0L + else + let interest = + decimal liability * rate * 1m + |> Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) + + let scheduledPrincipal = leasePayment - interest + if scheduledPrincipal < 0L then + invalidArg (nameof terms.LeasePayment) "Lease payment must cover at least the period interest." + let remainingPrincipalBeforeResidual = max 0L (liability - terms.ResidualValue) + let principal = + if paymentNum = totalPeriods then + remainingPrincipalBeforeResidual + else + min scheduledPrincipal remainingPrincipalBeforeResidual + if terms.LeasePayment > 0L && principal > scheduledPrincipal then + invalidArg (nameof terms.LeasePayment) "LeasePayment is too low to amortize to the stated residual by maturity." + let payment = interest + principal + let remaining = liability - principal + interest, principal, payment, remaining + + let item = { + PaymentNumber = paymentNum + PaymentDate = DateDay.Date(2000, 1, 1).AddMonths(paymentNum * monthsPerPeriod) + PaymentAmount = paymentAmount + PrincipalPortion = principalPortion + InterestPortion = interestPortion + RemainingLiability = newLiability + } + + generateSchedule (paymentNum + 1) newLiability (item :: acc) + + let initialLiability = + if terms.LeaseType = LeaseType.OperatingLease then 0L + else terms.FairMarketValue - terms.UpfrontPayment + + leasePayment, periodsPerYear, monthsPerPeriod, totalPeriods, rate, generateSchedule 1 initialLiability [] + + /// Calculate level lease payment with optional residual (balloon) + /// Assumptions: + /// - All incoming monetary values are int64 + /// - UpfrontPayment reduces financed amount + /// - ResidualValue discounted over total periods + /// - Interest.Rate.Annual is nominal annual; divided by payments/year + let calculateLeasePayment (terms: EquipmentLeaseTerms) : int64 = + buildScheduleCore terms |> fun (leasePayment, _, _, _, _, _) -> leasePayment + + /// Calculate lease payment details + let calculateLeaseDetails (terms: EquipmentLeaseTerms) : LeaseCalculation = + validateTerms terms + let leasePayment = + if terms.LeasePayment > 0L then terms.LeasePayment + else calculateLeasePayment terms + + let _, periodsPerYear, _, _, rate, schedule = buildScheduleCore terms + let totalPayments = (schedule |> Array.sumBy (fun item -> item.PaymentAmount)) + terms.UpfrontPayment + + let totalCost = + match terms.PurchaseOption with + | Some purchasePrice -> totalPayments + purchasePrice + | None -> totalPayments + + // Calculate present value of lease payments + let presentValue = + if rate = 0m then + totalPayments + else + let pv = schedule + |> Array.sumBy (fun item -> decimal item.PaymentAmount / pow (1m + rate) (decimal item.PaymentNumber)) + |> (+) (decimal terms.UpfrontPayment) + |> (*) 1m + Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) pv + + let aprEquivalent = + match terms.ImplicitRate with + | Interest.Rate.Zero -> Percent 0m + | Interest.Rate.Annual percent -> percent + | Interest.Rate.Daily (Percent rate) -> Percent (rate * 365m) + + { + LeasePayment = leasePayment + TotalPayments = totalPayments + TotalCost = totalCost + AprEquivalent = aprEquivalent + PresentValue = presentValue + } + + /// Generate lease payment schedule + let generateLeaseSchedule (terms: EquipmentLeaseTerms) (startDate: DateDay.Date) : LeaseScheduleItem array = + let _, _, monthsPerPeriod, _, _, schedule = buildScheduleCore terms + + schedule + |> Array.map (fun item -> + { item with PaymentDate = startDate.AddMonths(item.PaymentNumber * monthsPerPeriod) }) + + /// Analyze lease vs buy decision + type LeaseVsBuyAnalysis = { + /// Lease calculation details + LeaseDetails: LeaseCalculation + /// Depreciation schedule if equipment were purchased + PurchaseDepreciation: Types.DepreciationYear list + /// Lease payment schedule + LeaseSchedule: LeaseScheduleItem array + /// Net advantage to leasing (positive means leasing is better) + NetAdvantageToLeasing: int64 option + } + + /// Perform lease vs buy analysis + let analyzeLeaseVsBuy (terms: EquipmentLeaseTerms) (startDate: DateDay.Date) : LeaseVsBuyAnalysis = + let leaseDetails = calculateLeaseDetails terms + let leaseSchedule = generateLeaseSchedule terms startDate + + // Create depreciation schedule for purchase scenario + let macrsAsset = { + Types.CostBasis = terms.FairMarketValue + Types.PlacedInServiceDate = startDate + Types.PropertyClass = Calculations.classifyAsset terms.EquipmentDescription + Types.Convention = Types.Convention.HalfYear + } + + let depreciationSchedule = Calculations.generateSchedule macrsAsset + + { + LeaseDetails = leaseDetails + PurchaseDepreciation = depreciationSchedule + LeaseSchedule = leaseSchedule + NetAdvantageToLeasing = None // Would implement full NPV analysis in complete version + } diff --git a/src/EquipmentFinance/Loan.fs b/src/EquipmentFinance/Loan.fs new file mode 100644 index 00000000..adcf1b14 --- /dev/null +++ b/src/EquipmentFinance/Loan.fs @@ -0,0 +1,197 @@ +namespace FSharp.Finance.Personal.EquipmentFinance + +open System +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS + +/// Equipment loan calculations and analysis for equipment finance +module Loan = + + /// Simple power function for financial calculations + let private pow (base': decimal) (power: decimal) = + decimal (Math.Pow(double base', double power)) + + /// Terms and conditions for an equipment loan + type EquipmentLoanTerms = { + /// The financed principal amount of the loan, net of any down payment + Principal: int64 + /// The annual interest rate + InterestRate: Interest.Rate + /// The loan term in months + TermMonths: int + /// The monthly payment amount (if known) + MonthlyPayment: int64 option + /// The equipment being financed + EquipmentDescription: string + /// The cost of the equipment + EquipmentCost: int64 + /// Down payment made on the equipment + DownPayment: int64 + /// Residual value at end of loan term + ResidualValue: int64 + } + + /// Loan payment calculation result + type PaymentCalculation = { + /// The calculated monthly payment + MonthlyPayment: int64 + /// Total payments over the loan term + TotalPayments: int64 + /// Total interest paid over the loan term + TotalInterest: int64 + /// The annual percentage rate + Apr: Percent + } + + /// Loan amortization schedule item + type AmortizationItem = { + /// Payment number (1-based) + PaymentNumber: int + /// Payment due date + PaymentDate: DateDay.Date + /// The payment amount + PaymentAmount: int64 + /// Principal portion of payment + PrincipalPayment: int64 + /// Interest portion of payment + InterestPayment: int64 + /// Remaining principal balance + RemainingBalance: int64 + } + + let private validateTerms (terms: EquipmentLoanTerms) = + if terms.Principal <= 0L then invalidArg (nameof terms.Principal) "Principal must be > 0." + if terms.TermMonths <= 0 then invalidArg (nameof terms.TermMonths) "TermMonths must be > 0." + if terms.EquipmentCost <= 0L then invalidArg (nameof terms.EquipmentCost) "EquipmentCost must be > 0." + if terms.DownPayment < 0L then invalidArg (nameof terms.DownPayment) "DownPayment must be >= 0." + if terms.ResidualValue < 0L then invalidArg (nameof terms.ResidualValue) "ResidualValue must be >= 0." + if terms.ResidualValue >= terms.Principal then invalidArg (nameof terms.ResidualValue) "ResidualValue must be less than Principal." + + match terms.MonthlyPayment with + | Some payment when payment <= 0L -> invalidArg (nameof terms.MonthlyPayment) "MonthlyPayment must be > 0 when provided." + | _ -> () + + let private monthlyRate (interestRate: Interest.Rate) = + match interestRate with + | Interest.Rate.Zero -> 0m + | Interest.Rate.Annual (Percent rate) -> rate / 100m / 12m + | Interest.Rate.Daily (Percent rate) -> rate / 100m * 365m / 12m + + let private buildScheduleCore (terms: EquipmentLoanTerms) = + validateTerms terms + + let payment = + match terms.MonthlyPayment with + | Some monthlyPayment -> monthlyPayment + | None -> + let rate = monthlyRate terms.InterestRate + if rate = 0m then + let amortizedPrincipal = Cent.toDecimal (terms.Principal - terms.ResidualValue) + Cent.fromDecimal (amortizedPrincipal / decimal terms.TermMonths) + else + let growthFactor = pow (1m + rate) (decimal terms.TermMonths) + let residualPresentValue = decimal terms.ResidualValue / growthFactor + let amortizedPrincipal = decimal terms.Principal - residualPresentValue + let numerator = amortizedPrincipal * rate * growthFactor + let denominator = growthFactor - 1m + let installment = numerator / denominator + installment * 1m |> Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) + + let rate = monthlyRate terms.InterestRate + + let rec generateSchedule paymentNum balance acc = + if paymentNum > terms.TermMonths then + acc |> List.rev |> Array.ofList + else + let interestPayment = + decimal balance * rate * 1m + |> Cent.fromDecimalCent (RoundWith MidpointRounding.AwayFromZero) + + let paymentAmount, principalPayment = + if paymentNum = terms.TermMonths then + let finalPayment = balance + interestPayment + finalPayment, balance + else + let principal = payment - interestPayment + if principal <= 0L then + invalidArg (nameof terms.MonthlyPayment) "Monthly payment must exceed periodic interest." + payment, principal + + let newBalance = balance - principalPayment + generateSchedule (paymentNum + 1) newBalance ((paymentNum, paymentAmount, principalPayment, interestPayment, newBalance) :: acc) + + payment, generateSchedule 1 terms.Principal [] + + /// Calculate monthly payment for an equipment loan + let calculateMonthlyPayment (terms: EquipmentLoanTerms) : int64 = + buildScheduleCore terms |> fst + + /// Calculate payment details for an equipment loan + let calculatePaymentDetails (terms: EquipmentLoanTerms) : PaymentCalculation = + let monthlyPayment, schedule = buildScheduleCore terms + let totalPayments = schedule |> Array.sumBy (fun (_, paymentAmount, _, _, _) -> paymentAmount) + let totalInterest = totalPayments - terms.Principal + + // Simplified APR calculation (actual APR would require iterative calculation) + let annualRate = + match terms.InterestRate with + | Interest.Rate.Zero -> Percent 0m + | Interest.Rate.Annual percent -> percent + | Interest.Rate.Daily (Percent rate) -> Percent (rate * 365m) + + { + MonthlyPayment = monthlyPayment + TotalPayments = totalPayments + TotalInterest = totalInterest + Apr = annualRate + } + + /// Generate loan amortization schedule + let generateAmortizationSchedule (terms: EquipmentLoanTerms) (startDate: DateDay.Date) : AmortizationItem array = + let _, schedule = buildScheduleCore terms + + schedule + |> Array.map (fun (paymentNum, paymentAmount, principalPayment, interestPayment, remainingBalance) -> + { + PaymentNumber = paymentNum + PaymentDate = startDate.AddMonths(paymentNum) + PaymentAmount = paymentAmount + PrincipalPayment = principalPayment + InterestPayment = interestPayment + RemainingBalance = remainingBalance + }) + + /// Analyze equipment loan with depreciation considerations + type LoanAnalysis = { + /// Loan payment calculation + PaymentDetails: PaymentCalculation + /// Depreciation schedule for the equipment + DepreciationSchedule: Types.DepreciationYear list + /// Loan amortization schedule + AmortizationSchedule: AmortizationItem array + /// Net present value analysis would go here in a full implementation + NetPresentValue: int64 option + } + + /// Perform comprehensive analysis of an equipment loan + let analyzeLoan (terms: EquipmentLoanTerms) (startDate: DateDay.Date) : LoanAnalysis = + let paymentDetails = calculatePaymentDetails terms + let amortizationSchedule = generateAmortizationSchedule terms startDate + + // Create MACRS asset for depreciation analysis + let macrsAsset = { + Types.CostBasis = terms.EquipmentCost + Types.PlacedInServiceDate = startDate + Types.PropertyClass = Calculations.classifyAsset terms.EquipmentDescription + Types.Convention = Types.Convention.HalfYear + } + + let depreciationSchedule = Calculations.generateSchedule macrsAsset + + { + PaymentDetails = paymentDetails + DepreciationSchedule = depreciationSchedule + AmortizationSchedule = amortizationSchedule + NetPresentValue = None // Would implement NPV calculation in full version + } diff --git a/src/FSharp.Finance.Personal.fsproj b/src/FSharp.Finance.Personal.fsproj index 4e71d5e8..72d868d9 100644 --- a/src/FSharp.Finance.Personal.fsproj +++ b/src/FSharp.Finance.Personal.fsproj @@ -24,6 +24,11 @@ + + + + + diff --git a/src/Fee.fs b/src/Fee.fs index 9cd0c440..d3995edc 100644 --- a/src/Fee.fs +++ b/src/Fee.fs @@ -14,13 +14,13 @@ module Fee = [] type FeeType = /// a fee enabling the provision of a financial product - | FacilitationFee of Amount + | FacilitationFee of FacilitationAmount: Amount /// a fee charged by a Credit Access Business (CAB) or Credit Services Organisation (CSO) assisting access to third-party financial products - | CabOrCsoFee of Amount + | CabOrCsoFee of CabOrCsoAmount: Amount /// a fee charged by a bank or building society for arranging a mortgage - | MortageFee of Amount + | MortageFee of MortageAmount: Amount /// any other type of product fee - | CustomFee of string * Amount + | CustomFee of CustomDescription: string * CustomAmount: Amount /// HTML formatting to display the fee type in a readable format member ft.Html = diff --git a/src/Interest.fs b/src/Interest.fs index af7d367d..c3c0991c 100644 --- a/src/Interest.fs +++ b/src/Interest.fs @@ -14,9 +14,9 @@ module Interest = /// a zero rate | Zero /// the annual interest rate, or the daily interest rate multiplied by 365 - | Annual of Percent + | Annual of AnnualPercent: Percent /// the daily interest rate, or the annual interest rate divided by 365 - | Daily of Percent + | Daily of DailyPercent: Percent /// HTML formatting to display the rate in a readable format member r.Html = diff --git a/src/Scheduling.fs b/src/Scheduling.fs index a6d6a049..7888ea84 100644 --- a/src/Scheduling.fs +++ b/src/Scheduling.fs @@ -95,15 +95,15 @@ module Scheduling = [] type ActualPaymentStatus = /// a write-off payment has been applied - | WriteOff of int64 + | WriteOff of WriteOffAmount: int64 /// the payment has been initiated but is not yet confirmed - | Pending of int64 + | Pending of PendingAmount: int64 /// the payment had been initiated but was not confirmed within the timeout - | TimedOut of int64 + | TimedOut of TimedOutAmount: int64 /// the payment has been confirmed - | Confirmed of int64 + | Confirmed of ConfirmedAmount: int64 /// the payment has been failed, with optional charges (e.g. due to insufficient-funds penalties) - | Failed of int64 * Charge.ChargeType voption + | Failed of FailedAmount: int64 * FailedChargeType: Charge.ChargeType voption /// HTML formatting to display the actual payment status in a readable format member aps.Html = @@ -333,9 +333,9 @@ module Scheduling = /// no minimum payment | NoMinimumPayment /// add the payment due to the next payment or close the balance if the final payment - | DeferOrWriteOff of int64 + | DeferOrWriteOff of DeferOrWriteOffAmount: int64 /// take the minimum payment regardless - | ApplyMinimumPayment of int64 + | ApplyMinimumPayment of ApplyMinimumAmount: int64 /// HTML formatting to display the minimum payment in a readable format member mp.Html = diff --git a/tests/DepreciationCommonTests.fs b/tests/DepreciationCommonTests.fs new file mode 100644 index 00000000..c07cd558 --- /dev/null +++ b/tests/DepreciationCommonTests.fs @@ -0,0 +1,162 @@ +namespace FSharp.Finance.Personal.Tests + +open Xunit +open FsUnit.Xunit + +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.DepreciationCommon + +module DepreciationCommonTests = + + [] + let ``Validate positive amount accepts positive values`` () = + Validation.validatePositiveAmount 100L "test" // Should not throw + + [] + let ``Validate positive amount rejects zero and negative`` () = + (fun () -> Validation.validatePositiveAmount 0L "test") |> should throw typeof + (fun () -> Validation.validatePositiveAmount -10L "test") |> should throw typeof + + [] + let ``Validate percentage accepts valid range`` () = + Validation.validatePercentage 0m "test" // Should not throw + Validation.validatePercentage 0.5m "test" // Should not throw + Validation.validatePercentage 1m "test" // Should not throw + + [] + let ``Validate percentage rejects invalid range`` () = + (fun () -> Validation.validatePercentage -0.1m "test") |> should throw typeof + (fun () -> Validation.validatePercentage 1.1m "test") |> should throw typeof + + [] + let ``Calculate remaining value works`` () = + Calculations.calculateRemainingValue 10000L 3000L |> should equal 7000L + Calculations.calculateRemainingValue 5000L 5000L |> should equal 0L + + [] + let ``Apply rate works correctly`` () = + Calculations.applyRate 1000L 0.18m |> should equal 180L + Calculations.applyRate 5000L 0.06m |> should equal 300L + + [] + let ``Cap depreciation at cost works`` () = + let originalCost = 10000L + let cumulative = 5000L + + // Normal case - no capping needed + Calculations.capDepreciationAtCost originalCost 1000L cumulative |> should equal 1000L + + // Capping needed - proposed exceeds remaining + Calculations.capDepreciationAtCost originalCost 6000L cumulative |> should equal 5000L + + // Edge case - exactly at limit + Calculations.capDepreciationAtCost originalCost 5000L cumulative |> should equal 5000L + + [] + let ``Educational disclaimer is not empty`` () = + Disclaimers.EducationalDisclaimer |> should not' (be EmptyString) + Disclaimers.UKSpecificDisclaimer |> should not' (be EmptyString) + Disclaimers.USSpecificDisclaimer |> should not' (be EmptyString) + + [] + let ``Straight-line depreciation sums to cost minus salvage`` () = + let cost = 10000_00L // 10,000.00 + let salvage = 1000_00L // 1,000.00 + let life = 5 + + let schedule = Calculations.straightLine cost salvage life + schedule.Length |> should equal life + + let totalDep = + schedule |> Array.sumBy (fun p -> p.Depreciation) + + totalDep |> should equal (cost - salvage) + + // Final book value equals salvage + let last = schedule |> Array.last + last.BookValue |> should equal salvage + // Monotonic decline + schedule |> Array.pairwise |> Array.iter (fun (a,b) -> + b.BookValue |> should be (lessThanOrEqualTo a.BookValue)) + + // All methods flagged SL + schedule |> Array.iter (fun p -> p.Method |> should equal "SL") + + + [] + let ``Declining balance without switch reaches salvage and never goes below`` () = + let cost = 20000_00L // 20,000.00 + let salvage = 2000_00L // 2,000.00 + let life = 8 + let rateFactor = 2.0m + + let schedule = Calculations.decliningBalance cost salvage life rateFactor false + schedule.Length |> should equal life + + let final = schedule |> Array.last + final.BookValue |> should equal salvage + + // Never below salvage + schedule |> Array.iter (fun p -> p.BookValue |> should be (greaterThanOrEqualTo salvage)) + + // Methods should be only "DB" + schedule |> Array.iter (fun p -> p.Method |> should equal "DB") + + // Sum depreciation = cost - salvage + let totalDep = schedule |> Array.sumBy (fun p -> p.Depreciation) + totalDep |> should equal (cost - salvage) + + + [] + let ``Declining balance switches to straight-line for optimal write-off`` () = + let cost = 15000_00L + let salvage = 500_00L + let life = 6 + let rateFactor = 2.0m + + let schedule = Calculations.decliningBalance cost salvage life rateFactor true + schedule.Length |> should equal life + + // Ensure at least one period uses switch method + schedule |> Array.exists (fun p -> p.Method = "DB->SL") |> should equal true + + let final = Array.last schedule + final.BookValue |> should equal salvage + + // Sum depreciation = cost - salvage + let totalDep = schedule |> Array.sumBy (fun p -> p.Depreciation) + totalDep |> should equal (cost - salvage) + + // Monotonic decline + schedule |> Array.pairwise |> Array.iter (fun (a,b) -> + b.BookValue |> should be (lessThanOrEqualTo a.BookValue)) + + [] + let ``Straight-line final adjustment keeps salvage exact despite rounding`` () = + // Choose numbers that cause repeating decimals + let cost = 9999_99L + let salvage = 123_45L + let life = 7 + + let schedule = Calculations.straightLine cost salvage life + let final = schedule |> Array.last + final.BookValue |> should equal salvage + + // Total depreciation matches cost - salvage + schedule |> Array.sumBy (fun p -> p.Depreciation) + |> should equal (cost - salvage) + + [] + let ``Declining balance never depreciates below salvage mid-schedule`` () = + let cost = 8000_00L + let salvage = 1000_00L + let life = 10 + let rateFactor = 2.0m + + let schedule = Calculations.decliningBalance cost salvage life rateFactor true + + schedule + |> Array.take (schedule.Length - 1) + |> Array.iter (fun p -> p.BookValue |> should be (greaterThan salvage)) + + (Array.last schedule).BookValue |> should equal salvage \ No newline at end of file diff --git a/tests/EquipmentLeaseTests.fs b/tests/EquipmentLeaseTests.fs new file mode 100644 index 00000000..4a95feb3 --- /dev/null +++ b/tests/EquipmentLeaseTests.fs @@ -0,0 +1,335 @@ +namespace FSharp.Finance.Personal.Tests + +open Xunit +open FsUnit.Xunit +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance +open FSharp.Finance.Personal.EquipmentFinance.Lease + +module EquipmentLeaseTests = + + [] + let ``Payment frequency to payments per year conversion works`` () = + Lease.PaymentFrequency.Monthly.PaymentsPerYear |> should equal 12 + Lease.PaymentFrequency.Quarterly.PaymentsPerYear |> should equal 4 + Lease.PaymentFrequency.SemiAnnual.PaymentsPerYear |> should equal 2 + Lease.PaymentFrequency.Annual.PaymentsPerYear |> should equal 1 + + [] + let ``Lease payment calculation works for zero interest`` () = + let terms = { + EquipmentDescription = "Test equipment" + FairMarketValue = 1200_00L // $1,200 + TermMonths = 12 + LeaseType = Lease.LeaseType.OperatingLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 0L + UpfrontPayment = 0L + ResidualValue = 200_00L // $200 + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Zero + } + + let payment = Lease.calculateLeasePayment terms + // ($1200 - $200) / 12 = $83.33 + payment |> should equal 83_33L + + [] + let ``Lease payment calculation works with interest`` () = + let terms = { + EquipmentDescription = "Manufacturing equipment" + FairMarketValue = 10000_00L + TermMonths = 36 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 0L + UpfrontPayment = 1000_00L + ResidualValue = 2000_00L + PurchaseOption = Some 2000_00L + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 5.0m) + } + + let payment = Lease.calculateLeasePayment terms + + // Correct zero-interest baseline (uses upfront + residual) + let zeroInterestBaseline = + let initial = (terms.FairMarketValue - terms.UpfrontPayment - terms.ResidualValue) |> Cent.toDecimal + initial / (decimal (terms.TermMonths * terms.PaymentFrequency.PaymentsPerYear / 12)) + |> Cent.fromDecimal + + payment |> should be (greaterThan zeroInterestBaseline) + + // Expected theoretical payment (recalculate in test for robustness) + let r = 0.05m / 12m + let n = 36 + let principalNet = Cent.toDecimal terms.FairMarketValue - Cent.toDecimal terms.UpfrontPayment + let residual = Cent.toDecimal terms.ResidualValue + let growth = System.Math.Pow(float (1m + r), float n) |> decimal + let pvResidual = residual / growth + let baseAmt = principalNet - pvResidual + let annuityFactor = (1m - 1m / growth) / r + let expectedDec = baseAmt / annuityFactor + let expectedCents = Cent.fromDecimal expectedDec + // Allow 1 cent tolerance for rounding differences + abs (payment - expectedCents) |> should be (lessThanOrEqualTo 1L) + + [] + let ``Lease details calculation includes total cost`` () = + let terms = { + EquipmentDescription = "Office equipment" + FairMarketValue = 500000L // $5,000 + TermMonths = 24 + LeaseType = Lease.LeaseType.OperatingLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 200_00L // $200/month + UpfrontPayment = 500_00L // $500 + ResidualValue = 1000_00L // $1,000 + PurchaseOption = Some 1000_00L + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 4.0m) + } + + let details = Lease.calculateLeaseDetails terms + + details.LeasePayment |> should equal 20000L + details.TotalPayments |> should equal 530000L // $200*24 + $500 + details.TotalCost |> should equal 630000L // Total payments + purchase option + details.PresentValue |> should be (greaterThan 0L) + + [] + let ``Lease schedule has correct length`` () = + let terms = { + EquipmentDescription = "Computer" + FairMarketValue = 3000_00L // $3,000 + TermMonths = 12 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L // $250/month + UpfrontPayment = 0L + ResidualValue = 500_00L // $500 + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 3.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + + schedule.Length |> should equal 12 + + // First payment should have payment number 1 + schedule.[0].PaymentNumber |> should equal 1 + + // Last payment should have payment number equal to term + schedule.[11].PaymentNumber |> should equal 12 + + schedule.[11].RemainingLiability |> should equal 500_00L + schedule.[11].PaymentAmount |> should be (lessThanOrEqualTo 250_00L) + + [] + let ``Operating lease does not split principal and interest`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L // $6,000 + TermMonths = 24 + LeaseType = Lease.LeaseType.OperatingLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L // $250/month + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 4.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + + // Operating leases should not split principal/interest + schedule |> Array.iter (fun item -> + item.PrincipalPortion |> should equal 0L + item.InterestPortion |> should equal 0L) + + [] + let ``Finance lease splits principal and interest`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L // $6,000 + TermMonths = 24 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L // $250/month + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 4.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + + // Finance leases should split principal and interest + let firstPayment = schedule.[0] + firstPayment.PrincipalPortion |> should be (greaterThan 0L) + firstPayment.InterestPortion |> should be (greaterThan 0L) + + // Principal + interest should equal payment amount + (firstPayment.PrincipalPortion + firstPayment.InterestPortion) |> should equal firstPayment.PaymentAmount + + [] + let ``Finance lease schedule amortizes to residual not zero`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L + TermMonths = 24 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 4.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + let last = schedule |> Array.last + + last.RemainingLiability |> should equal 1000_00L + last.PaymentAmount |> should be (lessThanOrEqualTo 250_00L) + + [] + let ``Operating lease schedule carries no liability`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L + TermMonths = 24 + LeaseType = Lease.LeaseType.OperatingLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 4.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + + schedule |> Array.iter (fun item -> item.RemainingLiability |> should equal 0L) + + [] + let ``Lease details uses actual schedule totals and present value`` () = + let terms = { + EquipmentDescription = "Computer" + FairMarketValue = 3000_00L + TermMonths = 12 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 250_00L + UpfrontPayment = 0L + ResidualValue = 500_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 3.0m) + } + + let details = Lease.calculateLeaseDetails terms + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Lease.generateLeaseSchedule terms startDate + let scheduleTotal = schedule |> Array.sumBy (fun item -> item.PaymentAmount) + + details.TotalPayments |> should equal scheduleTotal + details.PresentValue |> should be (lessThanOrEqualTo details.TotalPayments) + + [] + let ``Finance lease rejects rental below period interest`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 10000_00L + TermMonths = 24 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 1_00L + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 12.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + (fun () -> Lease.generateLeaseSchedule terms startDate |> ignore) + |> should throw typeof + + [] + let ``Finance lease rejects rental that cannot reach residual by maturity`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L + TermMonths = 24 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 50_00L + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 4.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + (fun () -> Lease.generateLeaseSchedule terms startDate |> ignore) + |> should throw typeof + + [] + let ``Lease rejects upfront payment at or above fair value`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L + TermMonths = 24 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 0L + UpfrontPayment = 6000_00L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 4.0m) + } + + (fun () -> Lease.calculateLeasePayment terms |> ignore) + |> should throw typeof + + [] + let ``Lease vs buy analysis includes depreciation schedule`` () = + let terms = { + EquipmentDescription = "Manufacturing equipment" + FairMarketValue = 10000_00L // $10,000 + TermMonths = 36 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Monthly + LeasePayment = 300_00L // $300/month + UpfrontPayment = 1000_00L + ResidualValue = 2000_00L + PurchaseOption = Some 2000_00L + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 5.0m) + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let analysis = Lease.analyzeLeaseVsBuy terms startDate + + analysis.LeaseDetails.LeasePayment |> should equal 300_00L + analysis.PurchaseDepreciation |> should not' (be Empty) + analysis.LeaseSchedule.Length |> should equal 36 + + [] + let ``Lease rejects term incompatible with payment frequency`` () = + let terms = { + EquipmentDescription = "Equipment" + FairMarketValue = 6000_00L + TermMonths = 13 + LeaseType = Lease.LeaseType.FinanceLease + PaymentFrequency = Lease.PaymentFrequency.Quarterly + LeasePayment = 0L + UpfrontPayment = 0L + ResidualValue = 1000_00L + PurchaseOption = None + ImplicitRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 4.0m) + } + + (fun () -> Lease.calculateLeasePayment terms |> ignore) + |> should throw typeof diff --git a/tests/EquipmentLoanTests.fs b/tests/EquipmentLoanTests.fs new file mode 100644 index 00000000..4f3b29a6 --- /dev/null +++ b/tests/EquipmentLoanTests.fs @@ -0,0 +1,169 @@ +namespace FSharp.Finance.Personal.Tests + +open Xunit +open FsUnit.Xunit + +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance +open FSharp.Finance.Personal.EquipmentFinance.Loan + +module EquipmentLoanTests = + + [] + let ``Monthly payment calculation works for zero interest`` () = + let terms = { + Principal = 1000_00L // $1,000 + InterestRate = FSharp.Finance.Personal.Interest.Rate.Zero + TermMonths = 12 + MonthlyPayment = None + EquipmentDescription = "Test equipment" + EquipmentCost = 1000_00L + DownPayment = 0L + ResidualValue = 0L + } + + let payment = Loan.calculateMonthlyPayment terms + payment |> should equal 8333L // $1000/12 ≈ $83.33 + + [] + let ``Monthly payment calculation works with interest`` () = + let terms = { + Principal = 10000_00L // $10,000 + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 6.0m) + TermMonths = 36 + MonthlyPayment = None + EquipmentDescription = "Manufacturing equipment" + EquipmentCost = 10000_00L + DownPayment = 0L + ResidualValue = 0L + } + + let payment = Loan.calculateMonthlyPayment terms + // Payment should be greater than simple division due to interest + payment |> should be (greaterThan 27777L) // $10000/36 + + [] + let ``Monthly payment discounts balloon residual at non-zero interest`` () = + let terms = { + Principal = 10000_00L + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 6.0m) + TermMonths = 36 + MonthlyPayment = None + EquipmentDescription = "Manufacturing equipment" + EquipmentCost = 10000_00L + DownPayment = 0L + ResidualValue = 2000_00L + } + + let payment = Loan.calculateMonthlyPayment terms + let r = 0.06m / 12m + let n = 36 + let growth = System.Math.Pow(float (1m + r), float n) |> decimal + let pvResidual = 2000.00m / growth + let amortizedPrincipal = 10000.00m - pvResidual + let expected = amortizedPrincipal * r * growth / (growth - 1m) |> Cent.fromDecimal + + abs (payment - expected) |> should be (lessThanOrEqualTo 1L) + + [] + let ``Payment details calculation includes total payments and interest`` () = + let terms = { + Principal = 5000_00L // $5,000 + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 5.0m) + TermMonths = 24 + MonthlyPayment = None + EquipmentDescription = "Office equipment" + EquipmentCost = 5000_00L + DownPayment = 0L + ResidualValue = 0L + } + + let details = Loan.calculatePaymentDetails terms + + details.MonthlyPayment |> should be (greaterThan 0L) + details.TotalPayments |> should be (greaterThan terms.Principal) + details.TotalInterest |> should be (greaterThan 0L) + details.Apr |> should equal (FSharp.Finance.Personal.Calculation.Percent 5.0m) + + [] + let ``Amortization schedule has correct length`` () = + let terms = { + Principal = 3000_00L // $3,000 + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 4.0m) + TermMonths = 12 + MonthlyPayment = None + EquipmentDescription = "Computer" + EquipmentCost = 3000_00L + DownPayment = 0L + ResidualValue = 0L + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Loan.generateAmortizationSchedule terms startDate + + schedule.Length |> should equal 12 + + // First payment should have payment number 1 + schedule.[0].PaymentNumber |> should equal 1 + + // Last payment should have payment number equal to term + schedule.[11].PaymentNumber |> should equal 12 + + // Final balance should be 0 (or residual value) + schedule.[11].RemainingBalance |> should equal 0L + + [] + let ``Amortization schedule clears balloon on final payment`` () = + let terms = { + Principal = 10000_00L + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (Percent 6.0m) + TermMonths = 36 + MonthlyPayment = None + EquipmentDescription = "Manufacturing equipment" + EquipmentCost = 10000_00L + DownPayment = 0L + ResidualValue = 1000_00L + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let schedule = Loan.generateAmortizationSchedule terms startDate + let last = schedule |> Array.last + + last.RemainingBalance |> should equal 0L + last.PaymentAmount |> should be (greaterThan (Loan.calculateMonthlyPayment terms)) + + [] + let ``Invalid loan terms are rejected`` () = + let invalidTerms = { + Principal = 1000_00L + InterestRate = FSharp.Finance.Personal.Interest.Rate.Zero + TermMonths = 0 + MonthlyPayment = None + EquipmentDescription = "Test equipment" + EquipmentCost = 1000_00L + DownPayment = 0L + ResidualValue = 0L + } + + (fun () -> Loan.calculateMonthlyPayment invalidTerms |> ignore) + |> should throw typeof + + [] + let ``Loan analysis includes depreciation schedule`` () = + let terms = { + Principal = 10000_00L // $10,000 + InterestRate = FSharp.Finance.Personal.Interest.Rate.Annual (FSharp.Finance.Personal.Calculation.Percent 6.0m) + TermMonths = 36 + MonthlyPayment = None + EquipmentDescription = "Manufacturing equipment" + EquipmentCost = 10000_00L + DownPayment = 2000_00L + ResidualValue = 1000_00L + } + + let startDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + let analysis = Loan.analyzeLoan terms startDate + + analysis.PaymentDetails.MonthlyPayment |> should be (greaterThan 0L) + analysis.DepreciationSchedule |> should not' (be Empty) + analysis.AmortizationSchedule.Length |> should equal 36 diff --git a/tests/FSharp.Finance.Personal.Tests.fsproj b/tests/FSharp.Finance.Personal.Tests.fsproj index f7ddeeb6..af869d13 100644 --- a/tests/FSharp.Finance.Personal.Tests.fsproj +++ b/tests/FSharp.Finance.Personal.Tests.fsproj @@ -26,6 +26,11 @@ + + + + + diff --git a/tests/UKCapitalAllowancesTests.fs b/tests/UKCapitalAllowancesTests.fs new file mode 100644 index 00000000..f5c3e9ab --- /dev/null +++ b/tests/UKCapitalAllowancesTests.fs @@ -0,0 +1,155 @@ +namespace FSharp.Finance.Personal.Tests + +open Xunit +open FsUnit.Xunit + +open FSharp.Finance.Personal +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.UK_CapitalAllowances +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.UK_CapitalAllowances.Types + +module UKCapitalAllowancesTests = + + [] + let ``Default configuration has expected values`` () = + Types.Default.AnnualInvestmentAllowanceLimit |> should equal 1_000_000_00L + Types.Default.MainPoolRate |> should equal 0.18m + Types.Default.SpecialRatePoolRate |> should equal 0.06m + Types.Default.MaxYears |> should equal 10 + + [] + let ``Small asset fully claimed via AIA in year 1`` () = + let expenditure = { + Amount = 5_000_00L + Pool = Types.Pool.Main + Description = "Small equipment" + } + + let schedule = Calculations.scheduleDefault expenditure + + // Should have at least one year + schedule |> should not' (be Empty) + + // First year should claim full amount via AIA + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.AnnualInvestmentAllowance |> should equal 5_000_00L + year1.WritingDownAllowance |> should equal 0L + year1.TotalAllowances |> should equal 5_000_00L + year1.PoolValueEndOfYear |> should equal 0L + + [] + let ``Large asset partially claimed via AIA then WDA`` () = + let expenditure = { + Amount = 50_000_00L + Pool = Types.Pool.Main + Description = "Large equipment" + } + + let customConfig = { + Types.Default with + AnnualInvestmentAllowanceLimit = 10_000_00L + MaxYears = 3 + } + + let schedule = Calculations.generateSchedule customConfig expenditure + + // Should have multiple years + schedule.Length |> should be (greaterThan 1) + + // First year: £10k AIA, £7.2k WDA (18% of remaining £40k) + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.AnnualInvestmentAllowance |> should equal 10_000_00L + year1.WritingDownAllowance |> should equal 7_200_00L // 18% of 40k + year1.TotalAllowances |> should equal 17_200_00L + year1.PoolValueEndOfYear |> should equal 32_800_00L // 40k - 7.2k + + [] + let ``Special rate pool uses 6% WDA rate`` () = + let expenditure = { + Amount = 30_000_00L + Pool = Types.Pool.SpecialRate + Description = "Vehicle" + } + + let customConfig = { + Types.Default with + AnnualInvestmentAllowanceLimit = 20_000_00L + MaxYears = 2 + } + + let schedule = Calculations.generateSchedule customConfig expenditure + + // First year: £20k AIA, £0.6k WDA (6% of remaining £10k) + let year1 = schedule |> List.head + year1.AnnualInvestmentAllowance |> should equal 20_000_00L + year1.WritingDownAllowance |> should equal 600_00L // 6% of 10k + year1.PoolValueEndOfYear |> should equal 9_400_00L + + [] + let ``Example machinery schedule works`` () = + let schedule = Examples.exampleMachinerySchedule () + + schedule |> should not' (be Empty) + + let year1 = schedule |> List.head + year1.AnnualInvestmentAllowance |> should equal 50_000_00L // Full amount via AIA + year1.WritingDownAllowance |> should equal 0_00L + year1.TotalAllowances |> should equal 50_000_00L + + [] + let ``Example vehicle schedule works`` () = + let schedule = Examples.exampleVehicleSchedule () + + schedule |> should not' (be Empty) + + let year1 = schedule |> List.head + year1.AnnualInvestmentAllowance |> should equal 30_000_00L // Full amount via AIA + year1.WritingDownAllowance |> should equal 0_00L + year1.TotalAllowances |> should equal 30_000_00L + + [] + let ``Schedule continues until pool value is zero or max years reached`` () = + let expenditure = { + Amount = 1_000_00L + Pool = Types.Pool.Main + Description = "Small equipment" + } + + let customConfig = { + Types.Default with + AnnualInvestmentAllowanceLimit = 0_00L // No AIA + MaxYears = 50 + } + + let schedule = Calculations.generateSchedule customConfig expenditure + + // Should continue for multiple years until pool depleted + schedule.Length |> should be (greaterThan 15) + + // Last year should have pool value of 0 or very small + let lastYear = schedule |> List.last + lastYear.PoolValueEndOfYear |> should be (lessThanOrEqualTo 1_00L) + + [] + let ``Tiny residual pool is cleared instead of repeating zero WDA years`` () = + let expenditure = { + Amount = 0_03L + Pool = Types.Pool.Main + Description = "Tiny asset" + } + + let customConfig = { + Types.Default with + AnnualInvestmentAllowanceLimit = 0L + MaxYears = 10 + } + + let schedule = Calculations.generateSchedule customConfig expenditure + let lastYear = schedule |> List.last + + schedule.Length |> should be (lessThanOrEqualTo 2) + schedule |> List.exists (fun year -> year.WritingDownAllowance = 0L && year.PoolValueEndOfYear > 0L) |> should equal false + lastYear.WritingDownAllowance |> should be (greaterThan 0L) + lastYear.PoolValueEndOfYear |> should equal 0L diff --git a/tests/USMacrsTests.fs b/tests/USMacrsTests.fs new file mode 100644 index 00000000..05b39cf6 --- /dev/null +++ b/tests/USMacrsTests.fs @@ -0,0 +1,213 @@ +namespace FSharp.Finance.Personal.Tests + +open Xunit +open FsUnit.Xunit + +open FSharp.Finance.Personal.Calculation +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS +open FSharp.Finance.Personal.EquipmentFinance.Depreciation.US_MACRS.Types + +module USMacrsTests = + + [] + let ``Asset class recovery periods are correct`` () = + Calculations.getRecoveryPeriod Types.AssetClass.ThreeYear |> should equal 3 + Calculations.getRecoveryPeriod Types.AssetClass.FiveYear |> should equal 5 + Calculations.getRecoveryPeriod Types.AssetClass.SevenYear |> should equal 7 + Calculations.getRecoveryPeriod Types.AssetClass.TenYear |> should equal 10 + Calculations.getRecoveryPeriod Types.AssetClass.FifteenYear |> should equal 15 + Calculations.getRecoveryPeriod Types.AssetClass.TwentyYear |> should equal 20 + + [] + let ``Five-year property percentages are available`` () = + let percentages = Tables.getDepreciationPercentages Types.AssetClass.FiveYear + + percentages.Length |> should equal 6 + percentages.[0] |> should equal 20.00m + percentages.[1] |> should equal 32.00m + percentages.[2] |> should equal 19.20m + percentages.[3] |> should equal 11.52m + percentages.[4] |> should equal 11.52m + percentages.[5] |> should equal 5.76m + + [] + let ``Seven-year property percentages are available`` () = + let percentages = Tables.getDepreciationPercentages Types.AssetClass.SevenYear + + percentages.Length |> should equal 8 + percentages.[0] |> should equal 14.29m + percentages.[1] |> should equal 24.49m + + [] + let ``Five-year asset depreciation schedule is correct`` () = + let asset = { + CostBasis = 10000_00L // $10,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.FiveYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + + schedule.Length |> should equal 6 + + // Year 1: 20% of $10,000 = $2,000 + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.DepreciationRate |> should equal 0.20m + year1.DepreciationAmount |> should equal 2000_00L // $2,000 + year1.AccumulatedDepreciation |> should equal 2000_00L + year1.BookValue |> should equal 8000_00L + + // Year 2: 32% of $10,000 = $3,200 + let year2 = schedule.[1] + year2.Year |> should equal 2 + year2.DepreciationRate |> should equal 0.32m + year2.DepreciationAmount |> should equal 3200_00L // $3,200 + year2.AccumulatedDepreciation |> should equal 5200_00L + year2.BookValue |> should equal 4800_00L + + [] + let ``Three-year asset depreciation schedule is correct`` () = + let asset = { + CostBasis = 3000_00L // $3,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.ThreeYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + + schedule.Length |> should equal 4 + + // Year 1: 33.33% of $3,000 ≈ $999.90 + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.DepreciationAmount |> should (equalWithin 100L) 999_90L + + // Final year should have minimal book value + let lastYear = schedule |> List.last + lastYear.BookValue |> should be (lessThan 300_00L) // Most should be depreciated + + [] + let ``Total depreciation equals original basis`` () = + let asset = { + CostBasis = 500000L // $5,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.SevenYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + + let totalDepreciation = + schedule + |> List.sumBy (fun year -> year.DepreciationAmount) + + // Total should equal original basis (within rounding tolerance) + totalDepreciation |> should (equalWithin 50L) asset.CostBasis + + [] + let ``MACRS schedule is capped at full cost basis`` () = + let asset = { + CostBasis = 100_00L + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.ThreeYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + let lastYear = schedule |> List.last + + lastYear.AccumulatedDepreciation |> should equal asset.CostBasis + lastYear.BookValue |> should equal 0L + + [] + let ``Unsupported MACRS convention is rejected`` () = + let asset = { + CostBasis = 1000_00L + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.FiveYear + Convention = Types.Convention.MidQuarter + } + + (fun () -> Calculations.generateSchedule asset |> ignore) + |> should throw typeof + + [] + let ``Example computer schedule works`` () = + let schedule = Examples.exampleComputerSchedule () + + schedule |> should not' (be Empty) + schedule.Length |> should equal 6 + + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.DepreciationAmount |> should equal 2000_00L // 20% of $10,000 + + [] + let ``Example furniture schedule works`` () = + let schedule = Examples.exampleFurnitureSchedule () + + schedule |> should not' (be Empty) + schedule.Length |> should equal 8 // 7-year property has 8 years + + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.DepreciationAmount |> should equal 714_50L // 14.29% of $5,000 + + [] + let ``Example equipment schedule works`` () = + let schedule = Examples.exampleEquipmentSchedule () + + schedule |> should not' (be Empty) + schedule.Length |> should equal 8 + + let year1 = schedule |> List.head + year1.Year |> should equal 1 + year1.DepreciationAmount |> should equal 3572_50L // 14.29% of $25,000 + + [] + let ``Book value decreases each year`` () = + let asset = { + CostBasis = 15000_00L // $15,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.FiveYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + + // Book value should decrease each year + let bookValues = schedule |> List.map (fun year -> year.BookValue) + + bookValues + |> List.pairwise + |> List.iter (fun (prev, curr) -> curr |> should be (lessThan prev)) + + [] + let ``Accumulated depreciation increases each year`` () = + let asset = { + CostBasis = 8000_00L // $8,000 + PlacedInServiceDate = FSharp.Finance.Personal.DateDay.Date(2024, 1, 1) + PropertyClass = Types.AssetClass.ThreeYear + Convention = Types.Convention.HalfYear + } + + let schedule = Calculations.generateSchedule asset + + // Accumulated depreciation should increase each year + let accumulatedValues = schedule |> List.map (fun year -> year.AccumulatedDepreciation) + + accumulatedValues + |> List.pairwise + |> List.iter (fun (prev, curr) -> curr |> should be (greaterThan prev)) + + [] + let ``Asset classification works correctly`` () = + Calculations.classifyAsset "computer equipment" |> should equal Types.AssetClass.FiveYear + Calculations.classifyAsset "car" |> should equal Types.AssetClass.FiveYear + Calculations.classifyAsset "office furniture" |> should equal Types.AssetClass.SevenYear + Calculations.classifyAsset "manufacturing equipment" |> should equal Types.AssetClass.SevenYear + Calculations.classifyAsset "building" |> should equal Types.AssetClass.FifteenYear + Calculations.classifyAsset "general equipment" |> should equal Types.AssetClass.FiveYear // default