Skip to content

Commit 4222109

Browse files
committed
Merge branch 'main' into feature/transaction-entry-order-and-month-activity
2 parents 364f7d0 + 1d867c5 commit 4222109

20 files changed

Lines changed: 2403 additions & 43 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace CompoundCalc.Models.Requests;
4+
5+
public sealed class CarLoanRequest
6+
{
7+
public CarLoanRequest(
8+
decimal vehiclePrice,
9+
decimal cashDownPayment,
10+
decimal tradeInValue,
11+
decimal tradeInPayoff,
12+
decimal annualRatePercent,
13+
int termMonths,
14+
decimal? salesTaxPercent,
15+
decimal? salesTaxAmount,
16+
decimal fees,
17+
decimal rebate,
18+
decimal financedExtras)
19+
{
20+
VehiclePrice = vehiclePrice;
21+
CashDownPayment = cashDownPayment;
22+
TradeInValue = tradeInValue;
23+
TradeInPayoff = tradeInPayoff;
24+
AnnualRatePercent = annualRatePercent;
25+
TermMonths = termMonths;
26+
SalesTaxPercent = salesTaxPercent;
27+
SalesTaxAmount = salesTaxAmount;
28+
Fees = fees;
29+
Rebate = rebate;
30+
FinancedExtras = financedExtras;
31+
32+
Validate();
33+
}
34+
35+
[Required] public decimal VehiclePrice { get; }
36+
[Required] public decimal CashDownPayment { get; }
37+
[Required] public decimal TradeInValue { get; }
38+
[Required] public decimal TradeInPayoff { get; }
39+
[Required] public decimal AnnualRatePercent { get; }
40+
[Required] public int TermMonths { get; }
41+
public decimal? SalesTaxPercent { get; }
42+
public decimal? SalesTaxAmount { get; }
43+
public decimal Fees { get; }
44+
public decimal Rebate { get; }
45+
public decimal FinancedExtras { get; }
46+
47+
private void Validate()
48+
{
49+
if (VehiclePrice <= 0m)
50+
{
51+
throw new ArgumentOutOfRangeException(nameof(VehiclePrice), "Vehicle price must be greater than zero.");
52+
}
53+
54+
if (CashDownPayment < 0m)
55+
{
56+
throw new ArgumentOutOfRangeException(nameof(CashDownPayment), "Cash down payment cannot be negative.");
57+
}
58+
59+
if (TradeInValue < 0m)
60+
{
61+
throw new ArgumentOutOfRangeException(nameof(TradeInValue), "Trade-in value cannot be negative.");
62+
}
63+
64+
if (TradeInPayoff < 0m)
65+
{
66+
throw new ArgumentOutOfRangeException(nameof(TradeInPayoff), "Trade-in payoff cannot be negative.");
67+
}
68+
69+
if (AnnualRatePercent < 0m || AnnualRatePercent > 100m)
70+
{
71+
throw new ArgumentOutOfRangeException(nameof(AnnualRatePercent), "Annual rate percent must be between 0 and 100.");
72+
}
73+
74+
if (TermMonths is < 1 or > 96)
75+
{
76+
throw new ArgumentOutOfRangeException(nameof(TermMonths), "Term months must be between 1 and 96.");
77+
}
78+
79+
if (SalesTaxPercent is < 0m or > 100m)
80+
{
81+
throw new ArgumentOutOfRangeException(nameof(SalesTaxPercent), "Sales tax percent must be between 0 and 100.");
82+
}
83+
84+
if (SalesTaxAmount is < 0m)
85+
{
86+
throw new ArgumentOutOfRangeException(nameof(SalesTaxAmount), "Sales tax amount cannot be negative.");
87+
}
88+
89+
if (Fees < 0m)
90+
{
91+
throw new ArgumentOutOfRangeException(nameof(Fees), "Fees cannot be negative.");
92+
}
93+
94+
if (Rebate < 0m)
95+
{
96+
throw new ArgumentOutOfRangeException(nameof(Rebate), "Rebate cannot be negative.");
97+
}
98+
99+
if (FinancedExtras < 0m)
100+
{
101+
throw new ArgumentOutOfRangeException(nameof(FinancedExtras), "Financed extras cannot be negative.");
102+
}
103+
104+
if ((SalesTaxPercent.HasValue && SalesTaxAmount.HasValue) || (!SalesTaxPercent.HasValue && !SalesTaxAmount.HasValue))
105+
{
106+
throw new ArgumentException("Provide either sales tax percent or sales tax amount.");
107+
}
108+
}
109+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
namespace CompoundCalc.Models.Responses;
2+
3+
public sealed record CarLoanAmortizationEntry(
4+
int Month,
5+
decimal Payment,
6+
decimal Principal,
7+
decimal Interest,
8+
decimal RemainingBalance);
9+
10+
public sealed record CarLoanResult(
11+
decimal VehiclePrice,
12+
decimal CashDownPayment,
13+
decimal TradeInValue,
14+
decimal TradeInPayoff,
15+
decimal NetTradeInCredit,
16+
decimal TotalUpfrontCredit,
17+
decimal SalesTax,
18+
decimal Fees,
19+
decimal Rebate,
20+
decimal FinancedExtras,
21+
decimal AmountFinanced,
22+
decimal AnnualRatePercent,
23+
int TermMonths,
24+
decimal MonthlyPayment,
25+
decimal TotalPaid,
26+
decimal TotalInterest,
27+
string AmountFinancedDisplay,
28+
string MonthlyPaymentDisplay,
29+
string TotalPaidDisplay,
30+
string TotalInterestDisplay,
31+
string TotalUpfrontCreditDisplay,
32+
string NetTradeInCreditDisplay,
33+
string CalculationVersion,
34+
IReadOnlyList<CarLoanAmortizationEntry> AmortizationSchedule)
35+
{
36+
public static CarLoanResult Create(
37+
decimal vehiclePrice,
38+
decimal cashDownPayment,
39+
decimal tradeInValue,
40+
decimal tradeInPayoff,
41+
decimal netTradeInCredit,
42+
decimal totalUpfrontCredit,
43+
decimal salesTax,
44+
decimal fees,
45+
decimal rebate,
46+
decimal financedExtras,
47+
decimal amountFinanced,
48+
decimal annualRatePercent,
49+
int termMonths,
50+
decimal monthlyPayment,
51+
decimal totalPaid,
52+
decimal totalInterest,
53+
IReadOnlyList<CarLoanAmortizationEntry> amortizationSchedule,
54+
Func<decimal, string> currencyFormatter,
55+
string calculationVersion)
56+
{
57+
var roundedAmountFinanced = Math.Round(amountFinanced, 2, MidpointRounding.ToEven);
58+
var roundedMonthlyPayment = Math.Round(monthlyPayment, 2, MidpointRounding.ToEven);
59+
var roundedTotalPaid = Math.Round(totalPaid, 2, MidpointRounding.ToEven);
60+
var roundedTotalInterest = Math.Round(totalInterest, 2, MidpointRounding.ToEven);
61+
var roundedTotalUpfrontCredit = Math.Round(totalUpfrontCredit, 2, MidpointRounding.ToEven);
62+
var roundedNetTradeInCredit = Math.Round(netTradeInCredit, 2, MidpointRounding.ToEven);
63+
64+
return new CarLoanResult(
65+
VehiclePrice: vehiclePrice,
66+
CashDownPayment: cashDownPayment,
67+
TradeInValue: tradeInValue,
68+
TradeInPayoff: tradeInPayoff,
69+
NetTradeInCredit: roundedNetTradeInCredit,
70+
TotalUpfrontCredit: roundedTotalUpfrontCredit,
71+
SalesTax: Math.Round(salesTax, 2, MidpointRounding.ToEven),
72+
Fees: Math.Round(fees, 2, MidpointRounding.ToEven),
73+
Rebate: Math.Round(rebate, 2, MidpointRounding.ToEven),
74+
FinancedExtras: Math.Round(financedExtras, 2, MidpointRounding.ToEven),
75+
AmountFinanced: roundedAmountFinanced,
76+
AnnualRatePercent: annualRatePercent,
77+
TermMonths: termMonths,
78+
MonthlyPayment: roundedMonthlyPayment,
79+
TotalPaid: roundedTotalPaid,
80+
TotalInterest: roundedTotalInterest,
81+
AmountFinancedDisplay: currencyFormatter(roundedAmountFinanced),
82+
MonthlyPaymentDisplay: currencyFormatter(roundedMonthlyPayment),
83+
TotalPaidDisplay: currencyFormatter(roundedTotalPaid),
84+
TotalInterestDisplay: currencyFormatter(roundedTotalInterest),
85+
TotalUpfrontCreditDisplay: currencyFormatter(roundedTotalUpfrontCredit),
86+
NetTradeInCreditDisplay: currencyFormatter(roundedNetTradeInCredit),
87+
CalculationVersion: calculationVersion,
88+
AmortizationSchedule: amortizationSchedule);
89+
}
90+
}

CompoundCalc/Services/CalculationService.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,46 @@ public MortgageResult CalculateMortgageEstimate(MortgageRequest request)
126126
calculationVersion: DefaultCalculationVersion);
127127
}
128128

129+
public CarLoanResult CalculateCarLoanEstimate(CarLoanRequest request)
130+
{
131+
ArgumentNullException.ThrowIfNull(request);
132+
133+
var netTradeInCredit = Math.Max(request.TradeInValue - request.TradeInPayoff, 0m);
134+
var totalUpfrontCredit = request.CashDownPayment + netTradeInCredit;
135+
var taxableBase = request.VehiclePrice;
136+
var salesTax = request.SalesTaxAmount ?? (taxableBase * (request.SalesTaxPercent!.Value / 100m));
137+
var preCreditTotal = request.VehiclePrice + salesTax + request.Fees + request.FinancedExtras - request.Rebate;
138+
var amountFinanced = Math.Max(preCreditTotal - totalUpfrontCredit, 0m);
139+
140+
var monthlyRate = decimal.Divide(Conversions.ConvertPercentageToDecimal(request.AnnualRatePercent), 12m);
141+
var monthlyPayment = CalculateMonthlyMortgagePayment(amountFinanced, monthlyRate, request.TermMonths);
142+
143+
var amortization = BuildAmortizationSchedule(amountFinanced, monthlyRate, request.TermMonths, monthlyPayment);
144+
var totalPaid = amortization.Sum(x => x.Payment);
145+
var totalInterest = amortization.Sum(x => x.Interest);
146+
147+
return CarLoanResult.Create(
148+
vehiclePrice: request.VehiclePrice,
149+
cashDownPayment: request.CashDownPayment,
150+
tradeInValue: request.TradeInValue,
151+
tradeInPayoff: request.TradeInPayoff,
152+
netTradeInCredit: netTradeInCredit,
153+
totalUpfrontCredit: totalUpfrontCredit,
154+
salesTax: salesTax,
155+
fees: request.Fees,
156+
rebate: request.Rebate,
157+
financedExtras: request.FinancedExtras,
158+
amountFinanced: amountFinanced,
159+
annualRatePercent: request.AnnualRatePercent,
160+
termMonths: request.TermMonths,
161+
monthlyPayment: monthlyPayment,
162+
totalPaid: totalPaid,
163+
totalInterest: totalInterest,
164+
amortizationSchedule: amortization,
165+
currencyFormatter: Conversions.ConvertDecimalToCurrency,
166+
calculationVersion: DefaultCalculationVersion);
167+
}
168+
129169
private static CalculationResult RunCalculation(
130170
decimal principal,
131171
decimal annualRatePercent,
@@ -201,4 +241,38 @@ private static decimal CalculateMonthlyMortgagePayment(decimal principal, decima
201241

202242
return decimal.Divide(principal * monthlyRate, denominator);
203243
}
244+
245+
private static IReadOnlyList<CarLoanAmortizationEntry> BuildAmortizationSchedule(
246+
decimal principal,
247+
decimal monthlyRate,
248+
int termMonths,
249+
decimal scheduledPayment)
250+
{
251+
var schedule = new List<CarLoanAmortizationEntry>(termMonths);
252+
var remainingBalance = principal;
253+
254+
for (var month = 1; month <= termMonths; month++)
255+
{
256+
if (remainingBalance <= 0m)
257+
{
258+
break;
259+
}
260+
261+
var interest = monthlyRate > 0m
262+
? decimal.Round(remainingBalance * monthlyRate, 10, MidpointRounding.ToEven)
263+
: 0m;
264+
var payment = Math.Min(scheduledPayment, remainingBalance + interest);
265+
var principalPaid = payment - interest;
266+
remainingBalance = decimal.Round(Math.Max(remainingBalance - principalPaid, 0m), 10, MidpointRounding.ToEven);
267+
268+
schedule.Add(new CarLoanAmortizationEntry(
269+
Month: month,
270+
Payment: decimal.Round(payment, 2, MidpointRounding.ToEven),
271+
Principal: decimal.Round(principalPaid, 2, MidpointRounding.ToEven),
272+
Interest: decimal.Round(interest, 2, MidpointRounding.ToEven),
273+
RemainingBalance: decimal.Round(remainingBalance, 2, MidpointRounding.ToEven)));
274+
}
275+
276+
return schedule;
277+
}
204278
}

CompoundCalc/Services/Contracts/ICalculationService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ public interface ICalculationService
99
CalculationResult CalculateSavingsGrowth(SavingsCalcReq request);
1010
DebtPayoffResult CalculateDebtPayoff(DebtPayoffRequest request);
1111
MortgageResult CalculateMortgageEstimate(MortgageRequest request);
12+
CarLoanResult CalculateCarLoanEstimate(CarLoanRequest request);
1213
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.AspNetCore.Mvc.Testing;
5+
6+
namespace CompoundInterestCalculatorTests.Integration;
7+
8+
public class CarLoanControllerTests : IClassFixture<WebApplicationFactory<Program>>
9+
{
10+
private readonly WebApplicationFactory<Program> _factory;
11+
12+
public CarLoanControllerTests(WebApplicationFactory<Program> factory)
13+
{
14+
_factory = factory;
15+
}
16+
17+
[Fact]
18+
public async Task CarLoanEstimate_ReturnsPaymentTotalsAndAmortization()
19+
{
20+
var client = _factory.CreateClient();
21+
22+
var request = new
23+
{
24+
vehiclePrice = 35000m,
25+
cashDownPayment = 3000m,
26+
tradeInValue = 8000m,
27+
tradeInPayoff = 5000m,
28+
annualRatePercent = 6.5m,
29+
termMonths = 60,
30+
salesTaxPercent = 7.5m,
31+
fees = 1200m,
32+
rebate = 1000m,
33+
financedExtras = 500m
34+
};
35+
36+
var response = await client.PostAsJsonAsync("/api/v1/car-loan/estimate", request);
37+
38+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
39+
40+
var payload = await response.Content.ReadFromJsonAsync<CarLoanEstimateResponse>();
41+
Assert.NotNull(payload);
42+
Assert.Equal(6000m, payload!.totalUpfrontCredit);
43+
Assert.Equal(32325m, payload.amountFinanced);
44+
Assert.Equal(632.48m, payload.monthlyPayment);
45+
Assert.Equal(37948.80m, payload.totalPaid);
46+
Assert.Equal(5623.55m, payload.totalInterest);
47+
Assert.Equal(60, payload.amortizationSchedule.Count);
48+
Assert.False(string.IsNullOrWhiteSpace(payload.traceId));
49+
}
50+
51+
[Fact]
52+
public async Task CarLoanEstimate_WhenTaxNotSpecified_ReturnsValidationProblem()
53+
{
54+
var client = _factory.CreateClient();
55+
56+
var request = new
57+
{
58+
vehiclePrice = 35000m,
59+
cashDownPayment = 3000m,
60+
tradeInValue = 8000m,
61+
tradeInPayoff = 5000m,
62+
annualRatePercent = 6.5m,
63+
termMonths = 60,
64+
fees = 1200m,
65+
rebate = 1000m,
66+
financedExtras = 500m
67+
};
68+
69+
var response = await client.PostAsJsonAsync("/api/v1/car-loan/estimate", request);
70+
71+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
72+
73+
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
74+
Assert.NotNull(problem);
75+
}
76+
77+
private sealed record CarLoanEstimateResponse(
78+
decimal totalUpfrontCredit,
79+
decimal amountFinanced,
80+
decimal monthlyPayment,
81+
decimal totalPaid,
82+
decimal totalInterest,
83+
string traceId,
84+
IReadOnlyList<CarLoanAmortizationEntryResponse> amortizationSchedule);
85+
86+
private sealed record CarLoanAmortizationEntryResponse(
87+
int month,
88+
decimal payment,
89+
decimal principal,
90+
decimal interest,
91+
decimal remainingBalance);
92+
}

0 commit comments

Comments
 (0)