public static void AddInterest(this InstallmentPlanRow row, decimal interest, string description) { row.Description = row.Description + ", " + description; row.InterestRepayment += interest; row.Annuity += interest; row.NetCashFlow += interest; row.DiscountedNetCashFlow += interest; }
public static InstallmentPlanCalculationResult CalculateInstallmentPlan(SimpleLoanCalculationRequest request) { if (request.NumberOfInstallments == 0) { request.NumberOfInstallments = 1; } if (request.CalculationTarget == CalculationTarget.Term) { request.NumberOfInstallments = 12 * 200; // this will be cut off } //request.StardDate = request.StardDate.Date; // default installment schedule if (request.InstallmentSchedule == null) { request.InstallmentSchedule = new SimpleSchedule(); request.InstallmentSchedule.DayOfMonth = request.StardDate.Day; request.InstallmentSchedule.FrequencyPeriod = 1; request.InstallmentSchedule.FrequencyUnitOfTime = SimpleUnitOfTime.M; } if (request.InstallmentSchedule.DayOfMonth == 0) { request.InstallmentSchedule.DayOfMonth = request.StardDate.Day; } // default interest schedule if (request.InterestSchedule == null) { request.InterestSchedule = new SimpleSchedule(); request.InterestSchedule.DayOfMonth = request.InstallmentSchedule.DayOfMonth; request.InterestSchedule.FrequencyPeriod = request.InstallmentSchedule.FrequencyPeriod; request.InterestSchedule.FrequencyUnitOfTime = request.InstallmentSchedule.FrequencyUnitOfTime; } int dayOfMonth = request.InstallmentSchedule.DayOfMonth; DateTime startDate = request.StardDate; DateTime repaymentPeriodStartDate = request.StardDate; DateTime firstInstallmentDate = new DateTime(startDate.Year, startDate.Month, dayOfMonth); DateTime firstInterestDate = new DateTime(startDate.Year, startDate.Month, request.InterestSchedule.DayOfMonth); if (firstInterestDate <= startDate) { firstInterestDate = firstInterestDate.AddMonths(1); } if (firstInstallmentDate < startDate) { firstInstallmentDate = firstInstallmentDate.AddMonths(1); } Decimal intercalarInterest = 0; if (request.RegularInterest.Count != 1) { throw new NotImplementedException("Currently support only single interest rate entry"); } var inr = request.RegularInterest.FirstOrDefault(); if (firstInstallmentDate == startDate) { firstInstallmentDate = firstInstallmentDate.AddPeriod(request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime); } if (firstInterestDate == startDate) { firstInterestDate = firstInterestDate.AddPeriod(request.InterestSchedule.FrequencyPeriod, request.InterestSchedule.FrequencyUnitOfTime); } // for days 29..31 firstInstallmentDate = firstInstallmentDate.MoveTo(request.InstallmentSchedule.DayOfMonth); firstInterestDate = firstInterestDate.MoveTo(request.InterestSchedule.DayOfMonth); bool hasIntercalar = false; if (startDate.AddDays(request.MinimumDaysForFirstInstallment) > firstInstallmentDate) { // This means that date of first interest calculation and first installment will differ // Intercalar period must be forced repaymentPeriodStartDate = firstInterestDate; firstInstallmentDate = repaymentPeriodStartDate.AddPeriod(request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime); hasIntercalar = true; } decimal annuity = request.Amount / request.NumberOfInstallments; if (request.RepaymentType == RepaymentType.FixedAnnuity) { if (request.CalculationTarget == CalculationTarget.Amount) { decimal dummyAmount = 100000; annuity = (decimal)CalculateAnnuity(repaymentPeriodStartDate, firstInstallmentDate, request.NumberOfInstallments, request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime, inr.RatePercentage, inr.RateUnitOfTime, inr.IsCompound, inr.CalendarBasis, (double)dummyAmount, request.AdjustFirstInstallment); request.Amount = Math.Round(dummyAmount / annuity * request.Annuity, 2, MidpointRounding.ToEven); annuity = request.Annuity; } } if (request.CalculationTarget == CalculationTarget.Annuity && request.RepaymentType == RepaymentType.FixedAnnuity) { annuity = (decimal)CalculateAnnuity(repaymentPeriodStartDate, firstInstallmentDate, request.NumberOfInstallments, request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime, inr.RatePercentage, inr.RateUnitOfTime, inr.IsCompound, inr.CalendarBasis, (double)request.Amount, request.AdjustFirstInstallment); } else if (request.CalculationTarget == CalculationTarget.Annuity && request.RepaymentType == RepaymentType.FixedPrincipalRepayment) { annuity = request.Amount / request.NumberOfInstallments; } else if (request.CalculationTarget == CalculationTarget.Annuity && request.RepaymentType == RepaymentType.Bullet) { annuity = request.Amount; request.NumberOfInstallments = 1; } else { annuity = request.Annuity; } annuity = Math.Round(annuity, 2, MidpointRounding.ToEven); if (hasIntercalar && inr != null) { intercalarInterest = InterestCalculation.CalculateInterest(startDate, firstInterestDate, firstInterestDate, inr.RatePercentage, inr.RateUnitOfTime.CharLiteral(), inr.IsCompound, inr.CalendarBasis, (double)request.Amount).TotalInterest; intercalarInterest = Math.Round(intercalarInterest, 2, MidpointRounding.ToEven); } InstallmentPlanCalculationResult result = new InstallmentPlanCalculationResult { NumberOfInstallments = request.NumberOfInstallments, Annuity = annuity, Amount = request.Amount, Rows = new List <InstallmentPlanRow>() }; // Disbursement row InstallmentPlanRow row = new InstallmentPlanRow { Ordinal = 0, Date = startDate, ActivityKind = ActivityKind.Disbursement, Description = "Disbursement", Disbursement = request.Amount, NetCashFlow = -request.Amount }; row.DiscountedNetCashFlow = row.NetCashFlow; row.OutstandingBalance = request.Amount; result.Rows.Add(row); int ordinal = 1; bool addIntercalarToFirstInstallment = false; // Intercalary interest if (firstInterestDate != firstInstallmentDate && intercalarInterest != 0) { if (request.IntercalarInterestRepaymentType == IntercalarInterestRepaymentType.AfterIntercalarPeriod) { row = new InstallmentPlanRow { Ordinal = ordinal, Date = firstInterestDate, Description = "Intercalar interest", ActivityKind = ActivityKind.InterestPayment, InterestRepayment = intercalarInterest, OutstandingBalance = request.Amount, StartingBalance = request.Amount, Annuity = intercalarInterest, NetCashFlow = intercalarInterest, DiscountedNetCashFlow = intercalarInterest }; result.Rows.Add(row); ordinal++; } else if (request.IntercalarInterestRepaymentType == IntercalarInterestRepaymentType.WithDisbursement) { row.AddInterest(intercalarInterest, "Intercalar interest"); } else { // As this row is still not created, we leave this for later addIntercalarToFirstInstallment = true; } } DateTime d1 = repaymentPeriodStartDate; DateTime d2 = firstInstallmentDate; decimal outstanding = request.Amount; #region new way DateTime maturityDate = firstInstallmentDate.AddPeriod(request.InstallmentSchedule.FrequencyPeriod * (request.NumberOfInstallments - 1), request.InstallmentSchedule.FrequencyUnitOfTime); maturityDate = maturityDate.MoveTo(request.InstallmentSchedule.DayOfMonth); // correction for day of month 29..31 SortedDictionary <DateTime, InstlCalcRow> calcDates = new SortedDictionary <DateTime, InstlCalcRow>(); DateTime d = firstInterestDate; if (intercalarInterest != 0) // if there was intercalar interest calculated, then skip this date { d1 = firstInterestDate; d = d.AddPeriod(request.InterestSchedule.FrequencyPeriod, request.InterestSchedule.FrequencyUnitOfTime); d = d.MoveTo(request.InterestSchedule.DayOfMonth); // correction for day of month 29..31 } while (d < maturityDate) { calcDates.Add(d, new InstlCalcRow() { HasInterestRepayment = true }); d = d.AddPeriod(request.InterestSchedule.FrequencyPeriod, request.InterestSchedule.FrequencyUnitOfTime); d = d.MoveTo(request.InterestSchedule.DayOfMonth); // correction for day of month 29..31 } d = firstInstallmentDate; while (d < maturityDate) { if (calcDates.ContainsKey(d)) { calcDates[d].HasPrincipalRepayment = true; } else { calcDates.Add(d, new InstlCalcRow() { HasInterestRepayment = request.ForceInterestWithInstallment, HasPrincipalRepayment = true }); } d = d.AddPeriod(request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime); d = d.MoveTo(request.InstallmentSchedule.DayOfMonth); // correction for day of month 29..31 } // on maturity date we have to add interest and principal calcDates.Add(maturityDate, new InstlCalcRow() { HasInterestRepayment = true, HasPrincipalRepayment = true }); decimal inrCalc = 0; decimal inrBase = 0; bool installmentAdjusted = false; bool suddenEnd = false; decimal minimalInstallment = 1; // TODO: read from configuration foreach (var calcDate in calcDates) { d2 = calcDate.Key; inrBase = outstanding; if (inr.IsCompound) { inrBase = outstanding + inrCalc; } inrCalc = inrCalc + Math.Round(InterestCalculation.CalculateInterest(d1, d2, d2, inr.RatePercentage, inr.RateUnitOfTime.CharLiteral(), inr.IsCompound, inr.CalendarBasis, (double)inrBase).TotalInterest, 2, MidpointRounding.ToEven); decimal inrRepayment = 0; if (calcDate.Value.HasInterestRepayment) { inrRepayment = inrCalc; inrCalc = 0; } row = new InstallmentPlanRow { Ordinal = ordinal, Date = d2, ActivityKind = ActivityKind.Repayment, Description = "Repayment", StartingBalance = outstanding, InterestRepayment = inrRepayment }; if (calcDate.Value.HasPrincipalRepayment) { if (d2 == maturityDate) // if last installment { row.PrincipalRepayment = outstanding; } else if (request.AdjustFirstInstallment && !installmentAdjusted && d2 < maturityDate && request.RepaymentType == RepaymentType.FixedAnnuity) { // We calculate PrincipalRepayment using dummy interest from simplified installment plan where all periods are equal var dummyDate = d2.AddPeriod(-request.InstallmentSchedule.FrequencyPeriod, request.InstallmentSchedule.FrequencyUnitOfTime); var dummyInterest = InterestCalculation.CalculateInterest(dummyDate, d2, d2, inr.RatePercentage, inr.RateUnitOfTime.CharLiteral(), inr.IsCompound, inr.CalendarBasis, (double)outstanding).TotalInterest; dummyInterest = Math.Round(dummyInterest, 2, MidpointRounding.ToEven); row.PrincipalRepayment = annuity - dummyInterest; installmentAdjusted = true; } else if (request.RepaymentType == RepaymentType.FixedAnnuity) { row.PrincipalRepayment = annuity - row.InterestRepayment; } else // fixed principal repayment { row.PrincipalRepayment = annuity; } if (row.PrincipalRepayment > outstanding - minimalInstallment) { // break before reaching maturityDate (for example when calculating term) // minimalInstallment is used to avoid insignificant amounts in last installment row.PrincipalRepayment = outstanding; result.NumberOfInstallments = calcDates.Count(it => it.Key < d2 && it.Value.HasPrincipalRepayment) + 1; maturityDate = d2; suddenEnd = true; // force interest repayment if there wasn't one... row.InterestRepayment += inrCalc; inrCalc = 0; } } else { row.PrincipalRepayment = 0; } row.Annuity = row.PrincipalRepayment + row.InterestRepayment + row.Fee; row.OutstandingBalance = outstanding -= row.PrincipalRepayment; row.NetCashFlow = row.PrincipalRepayment + row.InterestRepayment + row.Fee; row.DiscountedNetCashFlow = row.PrincipalRepayment + row.InterestRepayment + row.Fee; result.Rows.Add(row); if (suddenEnd) { break; } ordinal++; d1 = d2; } #endregion if (addIntercalarToFirstInstallment) { var firstInstallment = result.Rows.FirstOrDefault(it => it.Date == firstInstallmentDate); if (firstInstallment != null) { firstInstallment.AddInterest(intercalarInterest, "Intercalar interest"); } } // Now fees if (request.Fees != null) { if (!request.FeeCurrencyConversionDone && !string.IsNullOrEmpty(request.Currency)) { AssecoCurrencyConvertion.CurrencyConverter currencyConverter = new AssecoCurrencyConvertion.CurrencyConverter(); foreach (var feeEntry in request.Fees) { if (!string.IsNullOrWhiteSpace(feeEntry.Currency) && feeEntry.Currency != request.Currency) { feeEntry.FixedAmountInDealCurrency = currencyConverter.CurrencyConvert(feeEntry.FixedAmount, feeEntry.Currency, request.Currency, request.StardDate.ToString("o", System.Globalization.CultureInfo.InvariantCulture), request.FeeCurrencyConversionMethod); feeEntry.LowerLimitInDealCurrency = currencyConverter.CurrencyConvert(feeEntry.LowerLimit, feeEntry.Currency, request.Currency, request.StardDate.ToString("o", System.Globalization.CultureInfo.InvariantCulture), request.FeeCurrencyConversionMethod); feeEntry.UpperLimitInDealCurrency = currencyConverter.CurrencyConvert(feeEntry.UpperLimit, feeEntry.Currency, request.Currency, request.StardDate.ToString("o", System.Globalization.CultureInfo.InvariantCulture), request.FeeCurrencyConversionMethod); } else { feeEntry.FixedAmountInDealCurrency = feeEntry.FixedAmount; feeEntry.LowerLimitInDealCurrency = feeEntry.LowerLimit; feeEntry.UpperLimitInDealCurrency = feeEntry.UpperLimit; } } } List <FeeEntry> newFeeDates = new List <FeeEntry>(); foreach (var feeEntry in request.Fees.Where(it => it.Frequency != FeeConditionFrequency.EventTriggered)) { d = feeEntry.Date; while (d < maturityDate) { switch (feeEntry.Frequency) { case FeeConditionFrequency.Monthly: d = d.AddMonths(1); break; case FeeConditionFrequency.Quarterly: d = d.AddMonths(3); break; case FeeConditionFrequency.Semiyearly: d = d.AddMonths(6); break; case FeeConditionFrequency.Yearly: d = d.AddYears(1); break; default: break; } // TODO: check if d is last date and if it should be applied bool applyFeeOnLastDate = false; if ((d <= maturityDate) && (applyFeeOnLastDate || d < d1)) { FeeEntry f = new FeeEntry { CalculationBasisType = feeEntry.CalculationBasisType, Currency = feeEntry.Currency, Date = d, FixedAmount = feeEntry.FixedAmount, Frequency = feeEntry.Frequency, Kind = feeEntry.Kind, LowerLimit = feeEntry.LowerLimit, Name = feeEntry.Name, Percentage = feeEntry.Percentage, ServiceCode = feeEntry.ServiceCode, TariffCode = feeEntry.TariffCode, UpperLimit = feeEntry.UpperLimit, FixedAmountInDealCurrency = feeEntry.FixedAmountInDealCurrency, LowerLimitInDealCurrency = feeEntry.LowerLimitInDealCurrency, UpperLimitInDealCurrency = feeEntry.UpperLimitInDealCurrency }; newFeeDates.Add(f); } } } foreach (var feeEntry in request.Fees.Union(newFeeDates)) { row = result.Rows.Where(it => it.Date == feeEntry.Date).LastOrDefault(); if (row == null) { row = new InstallmentPlanRow(); var prevDate = result.Rows.Where(it => it.Date < feeEntry.Date).Max(it => it.Date); int index = 0; if (prevDate != null) { var prevRow = result.Rows.Where(it => it.Date == prevDate).LastOrDefault(); index = result.Rows.IndexOf(prevRow) + 1; row.StartingBalance = prevRow.StartingBalance; row.OutstandingBalance = prevRow.OutstandingBalance; } result.Rows.Insert(index, row); row.Ordinal = 9999999; // we will have to renumerate this. row.Description = feeEntry.Name; } else { row.Description = row.Description + ", " + feeEntry.Name; } decimal feeBasis = request.Amount; if (feeEntry.CalculationBasisType == CalculationBasisType.AccountBalance) { feeBasis = row.OutstandingBalance; // as we calculate for future period } decimal fee = feeEntry.FixedAmountInDealCurrency + feeEntry.Percentage / 100 * feeBasis; if (feeEntry.UpperLimitInDealCurrency > 0 && fee > feeEntry.UpperLimitInDealCurrency) { fee = feeEntry.UpperLimitInDealCurrency; } if (fee < feeEntry.LowerLimitInDealCurrency) { fee = feeEntry.LowerLimitInDealCurrency; } fee = Math.Round(fee, 2, MidpointRounding.ToEven); row.Fee += fee; row.NetCashFlow += fee; row.DiscountedNetCashFlow = row.NetCashFlow; // TODO: Add installment plan column OtherPayments (Druge isplate) to support this /* * if (row.Disbursement > 0 && request.payFeeFromDisbursement) * { * row.Disbursement -= fee; * row.OtherPayments += fee; * * } */ } } // TODO: Check if there are rows with row.Ordinal = 9999999 and renumerate // At the end, APR calculation List <NetCashFlowItem> cashFlow = result.Rows.Select(it => new NetCashFlowItem() { Date = it.Date, NetCashFlow = -it.NetCashFlow, DiscountedNetCashFlow = -it.DiscountedNetCashFlow }).ToList(); result.APR = InterestCalculation.CalculateEffectiveInterestRate(cashFlow); // Update DiscountedNetCashFlow foreach (var item in result.Rows) { item.DiscountedNetCashFlow = InterestCalculation.CalculateDiscountedNetCashFlow((double)item.NetCashFlow, (double)result.APR, item.Date, startDate); } return(result); }