From d562fd99133a61c590269ed4bb903a7fc08cbd7c Mon Sep 17 00:00:00 2001 From: Jadeed Khan Date: Mon, 21 Jul 2025 21:58:02 +1000 Subject: [PATCH 1/4] Added feature to accept income/expense in different currencies and output calculation on preferred currency --- .../BasePredictionTest.cs | 7 +- .../Predictor.Tests.Integration.csproj | 1 + .../ApiPerformanceTests.cs | 89 ++++---- .../Predictor.Tests.Performance.csproj | 1 + .../Calculators/MonthCalculator.cs | 32 ++- src/Predictor.Web/ExampleData.cs | 197 +++++++++--------- .../Handlers/PredictionRequestHandler.cs | 6 +- src/Predictor.Web/Models/PaymentItem.cs | 11 +- src/Predictor.Web/Models/PredictionRequest.cs | 2 +- src/Predictor.Web/Predictor.Web.csproj | 1 + src/Predictor.Web/Program.cs | 3 + src/Predictor.Web/Services/CurrencyService.cs | 39 ++++ .../Validators/PaymentItemValidator.cs | 6 + .../Validators/PredictionRequestValidator.cs | 6 + 14 files changed, 245 insertions(+), 156 deletions(-) create mode 100644 src/Predictor.Web/Services/CurrencyService.cs diff --git a/src/Predictor.Tests.Integration/BasePredictionTest.cs b/src/Predictor.Tests.Integration/BasePredictionTest.cs index 7721109..e8649dd 100644 --- a/src/Predictor.Tests.Integration/BasePredictionTest.cs +++ b/src/Predictor.Tests.Integration/BasePredictionTest.cs @@ -43,15 +43,14 @@ protected async Task GetResponseStatusCode(PredictionRequest req protected static PredictionRequest CreateBasicRequest(int months = 3, decimal initialBudget = 0m) => new( PredictionMonths: months, InitialBudget: initialBudget, + OutputCurrency: "USD", StartPredictionMonth: new MonthDate(1, 2025), Incomes: [], Expenses: []); protected static PaymentItem CreateIncome(string name, decimal value, int month = 1, int year = 2025, - Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => - new(name, value, new MonthDate(month, year), frequency, endDate); + Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), "USD", frequency, endDate); protected static PaymentItem CreateExpense(string name, decimal value, int month = 1, int year = 2025, - Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => - new(name, value, new MonthDate(month, year), frequency, endDate); + Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), "USD", frequency, endDate); } \ No newline at end of file diff --git a/src/Predictor.Tests.Integration/Predictor.Tests.Integration.csproj b/src/Predictor.Tests.Integration/Predictor.Tests.Integration.csproj index 5ec2ffc..56550d3 100644 --- a/src/Predictor.Tests.Integration/Predictor.Tests.Integration.csproj +++ b/src/Predictor.Tests.Integration/Predictor.Tests.Integration.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Predictor.Tests.Performance/ApiPerformanceTests.cs b/src/Predictor.Tests.Performance/ApiPerformanceTests.cs index 2ba10ba..42964a8 100644 --- a/src/Predictor.Tests.Performance/ApiPerformanceTests.cs +++ b/src/Predictor.Tests.Performance/ApiPerformanceTests.cs @@ -190,20 +190,23 @@ private static void VerifyPerformanceStats(NodeStats stats, private static PredictionRequest CreateSimpleRequest() => new( PredictionMonths: 12, InitialBudget: 5000m, + "USD", StartPredictionMonth: new MonthDate(1, 2025), Incomes: [ - new("Salary", 4000m, new MonthDate(1, 2025), Frequency.Monthly), - new("Bonus", 2000m, new MonthDate(6, 2025), Frequency.OneTime) + new("Salary", 4000m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Bonus", 2000m, new MonthDate(6, 2025), "USD", Frequency.OneTime) ], Expenses: [ - new("Rent", 1200m, new MonthDate(1, 2025), Frequency.Monthly), - new("Food", 500m, new MonthDate(1, 2025), Frequency.Monthly), - new("Utilities", 200m, new MonthDate(1, 2025), Frequency.Monthly) + new("Rent", 1200m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Food", 500m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Utilities", 200m, new MonthDate(1, 2025), "USD", Frequency.Monthly) ] ); private static PredictionRequest CreateComplexRequest() => new( - PredictionMonths: 120, InitialBudget: 50000m, + PredictionMonths: 120, + InitialBudget: 50000m, + OutputCurrency: "USD", StartPredictionMonth: new MonthDate(1, 2025), Incomes: GenerateComplexIncomes(), Expenses: GenerateComplexExpenses() @@ -214,23 +217,23 @@ private static PaymentItem[] GenerateComplexIncomes() var incomes = new List(); incomes.AddRange([ - new("Primary Salary", 7000m, new MonthDate(1, 2025), Frequency.Monthly), - new("Spouse Salary", 5500m, new MonthDate(1, 2025), Frequency.Monthly), - new("Annual Raise Primary", 350m, new MonthDate(1, 2026), Frequency.Monthly), - new("Annual Raise Spouse", 275m, new MonthDate(1, 2026), Frequency.Monthly), + new("Primary Salary", 7000m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Spouse Salary", 5500m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Annual Raise Primary", 350m, new MonthDate(1, 2026), "USD", Frequency.Monthly), + new("Annual Raise Spouse", 275m, new MonthDate(1, 2026), "USD", Frequency.Monthly), ]); for (int year = 2025; year <= 2034; year++) { incomes.Add(new($"Dividend Income {year}", 400m + (year - 2025) * 50m, - new MonthDate(3, year), Frequency.Quarterly)); + new MonthDate(3, year), "USD", Frequency.Quarterly)); incomes.Add(new($"Annual Bonus {year}", 5000m + (year - 2025) * 200m, - new MonthDate(2, year), Frequency.OneTime)); + new MonthDate(2, year), "USD", Frequency.OneTime)); } - incomes.Add(new("Rental Property 1", 1800m, new MonthDate(6, 2025), Frequency.Monthly)); - incomes.Add(new("Rental Property 2", 2200m, new MonthDate(1, 2027), Frequency.Monthly)); - incomes.Add(new("Rental Property 3", 2500m, new MonthDate(6, 2029), Frequency.Monthly)); + incomes.Add(new("Rental Property 1", 1800m, new MonthDate(6, 2025), "USD", Frequency.Monthly)); + incomes.Add(new("Rental Property 2", 2200m, new MonthDate(1, 2027), "USD", Frequency.Monthly)); + incomes.Add(new("Rental Property 3", 2500m, new MonthDate(6, 2029), "USD", Frequency.Monthly)); for (int month = 1; month <= 120; month += 3) { @@ -239,7 +242,7 @@ private static PaymentItem[] GenerateComplexIncomes() if (monthDate.Year <= 2034) { incomes.Add(new($"Consulting Q{(month - 1) / 3 + 1}-{monthDate.Year}", - 1200m + month * 10m, monthDate, Frequency.OneTime)); + 1200m + month * 10m, monthDate, "USD", Frequency.OneTime)); } } @@ -251,46 +254,46 @@ private static PaymentItem[] GenerateComplexExpenses() var expenses = new List(); expenses.AddRange([ - new("Mortgage", 3200m, new MonthDate(1, 2025), Frequency.Monthly), - new("Property Tax", 650m, new MonthDate(1, 2025), Frequency.Monthly), - new("Home Insurance", 280m, new MonthDate(1, 2025), Frequency.Monthly), - new("Car Insurance", 220m, new MonthDate(1, 2025), Frequency.Monthly), - new("Health Insurance", 950m, new MonthDate(1, 2025), Frequency.Monthly), - new("Life Insurance", 180m, new MonthDate(1, 2025), Frequency.Monthly), - new("Utilities", 320m, new MonthDate(1, 2025), Frequency.Monthly), - new("Internet/Phone", 180m, new MonthDate(1, 2025), Frequency.Monthly), - new("Groceries", 900m, new MonthDate(1, 2025), Frequency.Monthly), - new("Transportation", 400m, new MonthDate(1, 2025), Frequency.Monthly), - new("Entertainment", 350m, new MonthDate(1, 2025), Frequency.Monthly), - new("Personal Care", 200m, new MonthDate(1, 2025), Frequency.Monthly), + new("Mortgage", 3200m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Property Tax", 650m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Home Insurance", 280m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Car Insurance", 220m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Health Insurance", 950m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Life Insurance", 180m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Utilities", 320m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Internet/Phone", 180m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Groceries", 900m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Transportation", 400m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Entertainment", 350m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Personal Care", 200m, new MonthDate(1, 2025), "USD", Frequency.Monthly), ]); expenses.AddRange([ - new("Car Loan 1", 520m, new MonthDate(1, 2025), Frequency.Monthly, new MonthDate(12, 2028)), - new("Car Loan 2", 480m, new MonthDate(6, 2027), Frequency.Monthly, new MonthDate(5, 2031)), - new("Student Loan", 380m, new MonthDate(1, 2025), Frequency.Monthly, new MonthDate(8, 2030)), - new("Personal Loan", 250m, new MonthDate(1, 2025), Frequency.Monthly, new MonthDate(12, 2027)), + new("Car Loan 1", 520m, new MonthDate(1, 2025), "USD", Frequency.Monthly, new MonthDate(12, 2028)), + new("Car Loan 2", 480m, new MonthDate(6, 2027), "USD", Frequency.Monthly, new MonthDate(5, 2031)), + new("Student Loan", 380m, new MonthDate(1, 2025), "USD", Frequency.Monthly, new MonthDate(8, 2030)), + new("Personal Loan", 250m, new MonthDate(1, 2025), "USD", Frequency.Monthly, new MonthDate(12, 2027)), ]); expenses.AddRange([ - new("401k Contribution", 1200m, new MonthDate(1, 2025), Frequency.Monthly), - new("IRA Contribution", 500m, new MonthDate(1, 2025), Frequency.Monthly), - new("Emergency Fund", 800m, new MonthDate(1, 2025), Frequency.Monthly, new MonthDate(12, 2027)), - new("Investment Account", 1500m, new MonthDate(1, 2025), Frequency.Monthly), + new("401k Contribution", 1200m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("IRA Contribution", 500m, new MonthDate(1, 2025), "USD", Frequency.Monthly), + new("Emergency Fund", 800m, new MonthDate(1, 2025), "USD", Frequency.Monthly, new MonthDate(12, 2027)), + new("Investment Account", 1500m, new MonthDate(1, 2025), "USD", Frequency.Monthly), ]); for (int year = 2025; year <= 2034; year++) { - expenses.Add(new($"Vacation {year}", 4500m, new MonthDate(7, year), Frequency.OneTime)); - expenses.Add(new($"Holiday Gifts {year}", 1200m, new MonthDate(12, year), Frequency.OneTime)); - expenses.Add(new($"Tax Preparation {year}", 400m, new MonthDate(3, year), Frequency.OneTime)); - expenses.Add(new($"Home Maintenance {year}", 2500m, new MonthDate(5, year), Frequency.OneTime)); + expenses.Add(new($"Vacation {year}", 4500m, new MonthDate(7, year), "USD", Frequency.OneTime)); + expenses.Add(new($"Holiday Gifts {year}", 1200m, new MonthDate(12, year), "USD", Frequency.OneTime)); + expenses.Add(new($"Tax Preparation {year}", 400m, new MonthDate(3, year), "USD", Frequency.OneTime)); + expenses.Add(new($"Home Maintenance {year}", 2500m, new MonthDate(5, year), "USD", Frequency.OneTime)); } expenses.AddRange([ - new("Property Maintenance", 800m, new MonthDate(3, 2025), Frequency.Quarterly), - new("Medical Checkups", 600m, new MonthDate(6, 2025), Frequency.SemiAnnually), - new("Car Maintenance", 450m, new MonthDate(4, 2025), Frequency.SemiAnnually), + new("Property Maintenance", 800m, new MonthDate(3, 2025), "USD", Frequency.Quarterly), + new("Medical Checkups", 600m, new MonthDate(6, 2025), "USD", Frequency.SemiAnnually), + new("Car Maintenance", 450m, new MonthDate(4, 2025), "USD", Frequency.SemiAnnually), ]); return [.. expenses]; diff --git a/src/Predictor.Tests.Performance/Predictor.Tests.Performance.csproj b/src/Predictor.Tests.Performance/Predictor.Tests.Performance.csproj index e3a3679..9110161 100644 --- a/src/Predictor.Tests.Performance/Predictor.Tests.Performance.csproj +++ b/src/Predictor.Tests.Performance/Predictor.Tests.Performance.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Predictor.Web/Calculators/MonthCalculator.cs b/src/Predictor.Web/Calculators/MonthCalculator.cs index 2ba8690..b1d2bfe 100644 --- a/src/Predictor.Web/Calculators/MonthCalculator.cs +++ b/src/Predictor.Web/Calculators/MonthCalculator.cs @@ -1,17 +1,39 @@ using Predictor.Web.Models; +using Predictor.Web.Services; namespace Predictor.Web.Calculators; - -public class MonthCalculator +public class MonthCalculator(ICurrencyService currencyService) { - public MonthOutput CalculateMonth(PredictionRequest input, MonthDate month, decimal budgetBefore) + private readonly ICurrencyService _currencyService = currencyService; + + public async Task CalculateMonthAsync(PredictionRequest input, MonthDate month, decimal budgetBefore) { - var income = input.GetMonthIncomes(month).Sum(x => x.Value); - var expense = input.GetMonthExpenses(month).Sum(x => x.Value); + var incomeTasks = input.GetMonthIncomes(month) + .Select(x => this.ConvertValue(x, input.OutputCurrency)); + var incomeValues = await Task.WhenAll(incomeTasks); + var income = incomeValues.Sum(); + + var expenseTasks = input.GetMonthExpenses(month) + .Select(x => this.ConvertValue(x, input.OutputCurrency)); + var expenseValues = await Task.WhenAll(expenseTasks); + var expense = expenseValues.Sum(); var balance = income - expense; var budgetAfter = budgetBefore + balance; return new MonthOutput(month, budgetAfter, balance, income, expense); } + + private async Task ConvertValue(PaymentItem item, string outputCurrency) + { + if (string.IsNullOrWhiteSpace(item.Currency) || + string.IsNullOrWhiteSpace(outputCurrency) || + item.Currency.Trim().Equals(outputCurrency.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return item.Value; + } + + var exchangeRate = await this._currencyService.GetExchangeRateAsync(item.Currency, outputCurrency); + return item.Value * exchangeRate; + } } diff --git a/src/Predictor.Web/ExampleData.cs b/src/Predictor.Web/ExampleData.cs index 92f3a19..7313eb1 100644 --- a/src/Predictor.Web/ExampleData.cs +++ b/src/Predictor.Web/ExampleData.cs @@ -7,114 +7,115 @@ public static class ExampleData public static PredictionRequest CalculateInputExample { get; } = new( PredictionMonths: 36, InitialBudget: 48_750, - StartPredictionMonth: MonthDate.Now, + OutputCurrency: "USD", + StartPredictionMonth: MonthDate.Now, Incomes: [ - new("Primary Salary", 5_400, MonthDate.Now, Frequency.Monthly), - new("Spouse Salary", 4_100, MonthDate.Now, Frequency.Monthly), - new("Rental Property A", 1_500, MonthDate.Now, Frequency.Monthly), - new("Rental Property B", 1_100, MonthDate.Now.AddMonths(3), Frequency.Monthly), - new("Investment Dividends", 320, MonthDate.Now.AddMonths(1), Frequency.Quarterly), - new("Side Business", 850, MonthDate.Now.AddMonths(2), Frequency.Monthly), + new("Primary Salary", 5_400, MonthDate.Now, "USD", Frequency.Monthly), + new("Spouse Salary", 4_100, MonthDate.Now, "USD", Frequency.Monthly), + new("Rental Property A", 1_500, MonthDate.Now, "USD", Frequency.Monthly), + new("Rental Property B", 1_100, MonthDate.Now.AddMonths(3), "USD", Frequency.Monthly), + new("Investment Dividends", 320, MonthDate.Now.AddMonths(1), "USD", Frequency.Quarterly), + new("Side Business", 850, MonthDate.Now.AddMonths(2), "USD", Frequency.Monthly), - new("Contract Work", 2_200, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(18)), - new("Consulting Retainer", 1_800, MonthDate.Now.AddMonths(1), Frequency.Monthly, MonthDate.Now.AddMonths(24)), - new("Freelance Project", 1_200, MonthDate.Now.AddMonths(2), Frequency.Quarterly, MonthDate.Now.AddMonths(14)), - new("Teaching Position", 900, MonthDate.Now.AddMonths(1), Frequency.Monthly, MonthDate.Now.AddMonths(10)), - new("Seasonal Work", 1_400, MonthDate.Now.AddMonths(4), Frequency.Monthly, MonthDate.Now.AddMonths(9)), - new("Part-time Job", 650, MonthDate.Now.AddMonths(6), Frequency.Monthly, MonthDate.Now.AddMonths(15)), - new("Quarterly Bonus", 2_500, MonthDate.Now.AddMonths(3), Frequency.Quarterly, MonthDate.Now.AddMonths(21)), - new("Investment Payout", 800, MonthDate.Now.AddMonths(2), Frequency.SemiAnnually, MonthDate.Now.AddMonths(26)), - new("Royalty Income", 450, MonthDate.Now.AddMonths(1), Frequency.Quarterly, MonthDate.Now.AddMonths(25)), - new("Rental Income C", 900, MonthDate.Now.AddMonths(12), Frequency.Monthly, MonthDate.Now.AddMonths(36)), + new("Contract Work", 2_200, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(18)), + new("Consulting Retainer", 1_800, MonthDate.Now.AddMonths(1), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(24)), + new("Freelance Project", 1_200, MonthDate.Now.AddMonths(2), "USD",Frequency.Quarterly, MonthDate.Now.AddMonths(14)), + new("Teaching Position", 900, MonthDate.Now.AddMonths(1), "USD",Frequency.Monthly, MonthDate.Now.AddMonths(10)), + new("Seasonal Work", 1_400, MonthDate.Now.AddMonths(4), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(9)), + new("Part-time Job", 650, MonthDate.Now.AddMonths(6), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(15)), + new("Quarterly Bonus", 2_500, MonthDate.Now.AddMonths(3), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(21)), + new("Investment Payout", 800, MonthDate.Now.AddMonths(2), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(26)), + new("Royalty Income", 450, MonthDate.Now.AddMonths(1), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(25)), + new("Rental Income C", 900, MonthDate.Now.AddMonths(12), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(36)), - new("Tax Refund", 3_200, MonthDate.Now.AddMonths(4)), - new("Insurance Settlement", 8_500, MonthDate.Now.AddMonths(7)), - new("Inheritance", 22_000, MonthDate.Now.AddMonths(11)), - new("Asset Sale", 15_000, MonthDate.Now.AddMonths(16)), - new("Lottery Winnings", 5_000, MonthDate.Now.AddMonths(19)), - new("Legal Settlement", 12_000, MonthDate.Now.AddMonths(28)), - new("Business Sale", 45_000, MonthDate.Now.AddMonths(32)), - new("Stock Options", 18_000, MonthDate.Now.AddMonths(35)), + new("Tax Refund", 3_200, MonthDate.Now.AddMonths(4), "USD"), + new("Insurance Settlement", 8_500, MonthDate.Now.AddMonths(7), "USD"), + new("Inheritance", 22_000, MonthDate.Now.AddMonths(11), "USD"), + new("Asset Sale", 15_000, MonthDate.Now.AddMonths(16), "USD"), + new("Lottery Winnings", 5_000, MonthDate.Now.AddMonths(19), "USD" ), + new("Legal Settlement", 12_000, MonthDate.Now.AddMonths(28), "USD"), + new("Business Sale", 45_000, MonthDate.Now.AddMonths(32), "USD"), + new("Stock Options", 18_000, MonthDate.Now.AddMonths(35), "USD"), ], Expenses: [ - new("Primary Mortgage", 2_300, MonthDate.Now, Frequency.Monthly), - new("Property Tax", 520, MonthDate.Now, Frequency.Monthly), - new("Home Insurance", 280, MonthDate.Now, Frequency.Monthly), - new("Car Insurance", 190, MonthDate.Now, Frequency.Monthly), - new("Health Insurance", 750, MonthDate.Now, Frequency.Monthly), - new("Life Insurance", 140, MonthDate.Now, Frequency.Monthly), - new("Phone Bills", 160, MonthDate.Now, Frequency.Monthly), - new("Internet", 95, MonthDate.Now, Frequency.Monthly), - new("Utilities", 220, MonthDate.Now, Frequency.Monthly), - new("Groceries", 720, MonthDate.Now, Frequency.Monthly), - new("Gasoline", 320, MonthDate.Now, Frequency.Monthly), - new("Dining Out", 450, MonthDate.Now, Frequency.Monthly), - new("Entertainment", 280, MonthDate.Now, Frequency.Monthly), - new("Personal Care", 180, MonthDate.Now, Frequency.Monthly), - new("Pet Expenses", 220, MonthDate.Now, Frequency.Monthly), - new("Charity", 350, MonthDate.Now, Frequency.Monthly), - new("Savings", 800, MonthDate.Now, Frequency.Monthly), - new("401k", 900, MonthDate.Now, Frequency.Monthly), - new("IRA", 650, MonthDate.Now, Frequency.Monthly), - new("Investment Account", 1_200, MonthDate.Now, Frequency.Monthly), + new("Primary Mortgage", 2_300, MonthDate.Now, "USD", Frequency.Monthly), + new("Property Tax", 520, MonthDate.Now, "USD", Frequency.Monthly), + new("Home Insurance", 280, MonthDate.Now, "USD", Frequency.Monthly), + new("Car Insurance", 190, MonthDate.Now, "USD", Frequency.Monthly), + new("Health Insurance", 750, MonthDate.Now, "USD", Frequency.Monthly), + new("Life Insurance", 140, MonthDate.Now, "USD", Frequency.Monthly), + new("Phone Bills", 160, MonthDate.Now, "USD", Frequency.Monthly), + new("Internet", 95, MonthDate.Now, "USD", Frequency.Monthly), + new("Utilities", 220, MonthDate.Now, "USD", Frequency.Monthly), + new("Groceries", 720, MonthDate.Now, "USD", Frequency.Monthly), + new("Gasoline", 320, MonthDate.Now, "USD", Frequency.Monthly), + new("Dining Out", 450, MonthDate.Now, "USD", Frequency.Monthly), + new("Entertainment", 280, MonthDate.Now, "USD", Frequency.Monthly), + new("Personal Care", 180, MonthDate.Now, "USD", Frequency.Monthly), + new("Pet Expenses", 220, MonthDate.Now, "USD", Frequency.Monthly), + new("Charity", 350, MonthDate.Now, "USD", Frequency.Monthly), + new("Savings", 800, MonthDate.Now, "USD", Frequency.Monthly), + new("401k", 900, MonthDate.Now, "USD", Frequency.Monthly), + new("IRA", 650, MonthDate.Now, "USD", Frequency.Monthly), + new("Investment Account", 1_200, MonthDate.Now, "USD", Frequency.Monthly), - new("Student Loan 1", 480, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(48)), - new("Student Loan 2", 320, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(36)), - new("Car Payment 1", 580, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(42)), - new("Car Payment 2", 420, MonthDate.Now.AddMonths(6), Frequency.Monthly, MonthDate.Now.AddMonths(54)), - new("Personal Loan", 380, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(24)), - new("Credit Card Payment", 450, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(18)), - new("Gym Membership", 85, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(12)), - new("Streaming Services", 120, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(24)), - new("Child Care", 1_200, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(60)), - new("Tutoring", 300, MonthDate.Now.AddMonths(2), Frequency.Monthly, MonthDate.Now.AddMonths(22)), - new("Music Lessons", 180, MonthDate.Now.AddMonths(1), Frequency.Monthly, MonthDate.Now.AddMonths(18)), - new("Sports Club", 150, MonthDate.Now.AddMonths(3), Frequency.Monthly, MonthDate.Now.AddMonths(15)), - new("Rental Property A Mortgage", 950, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(180)), - new("Rental Property B Mortgage", 720, MonthDate.Now.AddMonths(3), Frequency.Monthly, MonthDate.Now.AddMonths(240)), - new("Business Loan", 850, MonthDate.Now.AddMonths(2), Frequency.Monthly, MonthDate.Now.AddMonths(84)), - new("Equipment Lease", 280, MonthDate.Now.AddMonths(1), Frequency.Monthly, MonthDate.Now.AddMonths(36)), - new("Software Subscription", 95, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(12)), - new("Professional Membership", 75, MonthDate.Now, Frequency.Monthly, MonthDate.Now.AddMonths(24)), + new("Student Loan 1", 480, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(48)), + new("Student Loan 2", 320, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(36)), + new("Car Payment 1", 580, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(42)), + new("Car Payment 2", 420, MonthDate.Now.AddMonths(6), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(54)), + new("Personal Loan", 380, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(24)), + new("Credit Card Payment", 450, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(18)), + new("Gym Membership", 85, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(12)), + new("Streaming Services", 120, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(24)), + new("Child Care", 1_200, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(60)), + new("Tutoring", 300, MonthDate.Now.AddMonths(2), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(22)), + new("Music Lessons", 180, MonthDate.Now.AddMonths(1), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(18)), + new("Sports Club", 150, MonthDate.Now.AddMonths(3), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(15)), + new("Rental Property A Mortgage", 950, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(180)), + new("Rental Property B Mortgage", 720, MonthDate.Now.AddMonths(3), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(240)), + new("Business Loan", 850, MonthDate.Now.AddMonths(2), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(84)), + new("Equipment Lease", 280, MonthDate.Now.AddMonths(1), "USD", Frequency.Monthly, MonthDate.Now.AddMonths(36)), + new("Software Subscription", 95, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(12)), + new("Professional Membership", 75, MonthDate.Now, "USD", Frequency.Monthly, MonthDate.Now.AddMonths(24)), - new("Property Management", 180, MonthDate.Now.AddMonths(1), Frequency.Quarterly, MonthDate.Now.AddMonths(36)), - new("Lawn Service", 220, MonthDate.Now.AddMonths(2), Frequency.Quarterly, MonthDate.Now.AddMonths(30)), - new("Pest Control", 140, MonthDate.Now, Frequency.Quarterly, MonthDate.Now.AddMonths(24)), - new("HVAC Maintenance", 300, MonthDate.Now.AddMonths(3), Frequency.Quarterly, MonthDate.Now.AddMonths(48)), - new("Pool Maintenance", 180, MonthDate.Now.AddMonths(1), Frequency.Quarterly, MonthDate.Now.AddMonths(21)), + new("Property Management", 180, MonthDate.Now.AddMonths(1), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(36)), + new("Lawn Service", 220, MonthDate.Now.AddMonths(2), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(30)), + new("Pest Control", 140, MonthDate.Now, "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(24)), + new("HVAC Maintenance", 300, MonthDate.Now.AddMonths(3), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(48)), + new("Pool Maintenance", 180, MonthDate.Now.AddMonths(1), "USD", Frequency.Quarterly, MonthDate.Now.AddMonths(21)), - new("Car Maintenance", 650, MonthDate.Now.AddMonths(3), Frequency.SemiAnnually, MonthDate.Now.AddMonths(42)), - new("Home Maintenance", 1_200, MonthDate.Now.AddMonths(2), Frequency.SemiAnnually, MonthDate.Now.AddMonths(60)), - new("Medical Checkups", 800, MonthDate.Now.AddMonths(4), Frequency.SemiAnnually, MonthDate.Now.AddMonths(48)), - new("Dental Work", 450, MonthDate.Now.AddMonths(1), Frequency.SemiAnnually, MonthDate.Now.AddMonths(36)), - new("Eye Exams", 280, MonthDate.Now.AddMonths(5), Frequency.SemiAnnually, MonthDate.Now.AddMonths(30)), - new("Veterinary Care", 350, MonthDate.Now.AddMonths(2), Frequency.SemiAnnually, MonthDate.Now.AddMonths(54)), + new("Car Maintenance", 650, MonthDate.Now.AddMonths(3), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(42)), + new("Home Maintenance", 1_200, MonthDate.Now.AddMonths(2), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(60)), + new("Medical Checkups", 800, MonthDate.Now.AddMonths(4), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(48)), + new("Dental Work", 450, MonthDate.Now.AddMonths(1), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(36)), + new("Eye Exams", 280, MonthDate.Now.AddMonths(5), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(30)), + new("Veterinary Care", 350, MonthDate.Now.AddMonths(2), "USD", Frequency.SemiAnnually, MonthDate.Now.AddMonths(54)), - new("Vacation Fund", 4_500, MonthDate.Now.AddMonths(6), Frequency.Annually, MonthDate.Now.AddMonths(60)), - new("Holiday Gifts", 1_500, MonthDate.Now.AddMonths(11), Frequency.Annually, MonthDate.Now.AddMonths(48)), - new("Tax Preparation", 450, MonthDate.Now.AddMonths(3), Frequency.Annually, MonthDate.Now.AddMonths(36)), - new("Insurance Premium", 950, MonthDate.Now.AddMonths(8), Frequency.Annually, MonthDate.Now.AddMonths(72)), - new("Professional Development", 800, MonthDate.Now.AddMonths(4), Frequency.Annually, MonthDate.Now.AddMonths(60)), - new("Charity Donation", 1_200, MonthDate.Now.AddMonths(10), Frequency.Annually, MonthDate.Now.AddMonths(84)), - new("Home Warranty", 600, MonthDate.Now.AddMonths(7), Frequency.Annually, MonthDate.Now.AddMonths(36)), + new("Vacation Fund", 4_500, MonthDate.Now.AddMonths(6), "USD", Frequency.Annually, MonthDate.Now.AddMonths(60)), + new("Holiday Gifts", 1_500, MonthDate.Now.AddMonths(11), "USD", Frequency.Annually, MonthDate.Now.AddMonths(48)), + new("Tax Preparation", 450, MonthDate.Now.AddMonths(3), "USD", Frequency.Annually, MonthDate.Now.AddMonths(36)), + new("Insurance Premium", 950, MonthDate.Now.AddMonths(8), "USD", Frequency.Annually, MonthDate.Now.AddMonths(72)), + new("Professional Development", 800, MonthDate.Now.AddMonths(4), "USD", Frequency.Annually, MonthDate.Now.AddMonths(60)), + new("Charity Donation", 1_200, MonthDate.Now.AddMonths(10), "USD", Frequency.Annually, MonthDate.Now.AddMonths(84)), + new("Home Warranty", 600, MonthDate.Now.AddMonths(7), "USD", Frequency.Annually, MonthDate.Now.AddMonths(36)), - new("Emergency Car Repair", 1_800, MonthDate.Now.AddMonths(2)), - new("Appliance Replacement", 2_400, MonthDate.Now.AddMonths(5)), - new("Wedding Gift", 750, MonthDate.Now.AddMonths(8)), - new("Computer Upgrade", 3_200, MonthDate.Now.AddMonths(10)), - new("Roof Repair", 8_500, MonthDate.Now.AddMonths(12)), - new("Kitchen Renovation", 22_000, MonthDate.Now.AddMonths(15)), - new("Bathroom Remodel", 12_000, MonthDate.Now.AddMonths(18)), - new("Flooring Replacement", 9_500, MonthDate.Now.AddMonths(21)), - new("New Car Down Payment", 12_000, MonthDate.Now.AddMonths(24)), - new("College Tuition", 18_000, MonthDate.Now.AddMonths(27)), - new("Medical Emergency", 6_500, MonthDate.Now.AddMonths(30)), - new("Legal Fees", 4_200, MonthDate.Now.AddMonths(33)), - new("Business Investment", 35_000, MonthDate.Now.AddMonths(36)), - new("Home Addition", 45_000, MonthDate.Now.AddMonths(39)), - new("Luxury Purchase", 18_000, MonthDate.Now.AddMonths(42)), - new("Investment Property Down Payment", 25_000, MonthDate.Now.AddMonths(45)), - new("Retirement Catch-up", 30_000, MonthDate.Now.AddMonths(48)) + new("Emergency Car Repair", 1_800, MonthDate.Now.AddMonths(2), "USD"), + new("Appliance Replacement", 2_400, MonthDate.Now.AddMonths(5), "USD"), + new("Wedding Gift", 750, MonthDate.Now.AddMonths(8), "USD"), + new("Computer Upgrade", 3_200, MonthDate.Now.AddMonths(10), "USD"), + new("Roof Repair", 8_500, MonthDate.Now.AddMonths(12), "USD"), + new("Kitchen Renovation", 22_000, MonthDate.Now.AddMonths(15), "USD" ), + new("Bathroom Remodel", 12_000, MonthDate.Now.AddMonths(18), "USD"), + new("Flooring Replacement", 9_500, MonthDate.Now.AddMonths(21), "USD"), + new("New Car Down Payment", 12_000, MonthDate.Now.AddMonths(24), "USD"), + new("College Tuition", 18_000, MonthDate.Now.AddMonths(27), "USD"), + new("Medical Emergency", 6_500, MonthDate.Now.AddMonths(30), "USD"), + new("Legal Fees", 4_200, MonthDate.Now.AddMonths(33), "USD"), + new("Business Investment", 35_000, MonthDate.Now.AddMonths(36), "USD"), + new("Home Addition", 45_000, MonthDate.Now.AddMonths(39), "USD"), + new("Luxury Purchase", 18_000, MonthDate.Now.AddMonths(42), "USD"), + new("Investment Property Down Payment", 25_000, MonthDate.Now.AddMonths(45), "USD"), + new("Retirement Catch-up", 30_000, MonthDate.Now.AddMonths(48), "USD") ] ); } \ No newline at end of file diff --git a/src/Predictor.Web/Handlers/PredictionRequestHandler.cs b/src/Predictor.Web/Handlers/PredictionRequestHandler.cs index a9769e7..850aaeb 100644 --- a/src/Predictor.Web/Handlers/PredictionRequestHandler.cs +++ b/src/Predictor.Web/Handlers/PredictionRequestHandler.cs @@ -12,7 +12,7 @@ public class PredictionRequestHandler( MonthCalculator calculator) : IRequestHandler { - public Task Handle(PredictionRequest request, CancellationToken cancellationToken) + public async Task Handle(PredictionRequest request, CancellationToken cancellationToken) { validator.ValidateAndThrow(request); @@ -20,7 +20,7 @@ public Task Handle(PredictionRequest request, CancellationToke var budget = request.InitialBudget; foreach (var currentMonth in MonthDate.Range(request.StartPredictionMonth, request.PredictionMonths)) { - var month = calculator.CalculateMonth(request, currentMonth, budget); + var month = await calculator.CalculateMonthAsync(request, currentMonth, budget); budget = month.BudgetAfter; months.Add(month); } @@ -33,6 +33,6 @@ public Task Handle(PredictionRequest request, CancellationToke var result = new PredictionResult(request.PutId ?? Guid.NewGuid(), summary, monthsArray); cache.Set_PredictionResult(result); - return Task.FromResult(result); + return await Task.FromResult(result); } } diff --git a/src/Predictor.Web/Models/PaymentItem.cs b/src/Predictor.Web/Models/PaymentItem.cs index 22a4f2c..fa22c15 100644 --- a/src/Predictor.Web/Models/PaymentItem.cs +++ b/src/Predictor.Web/Models/PaymentItem.cs @@ -1,6 +1,8 @@ -namespace Predictor.Web.Models; +using ISO._4217.Models; -public record PaymentItem(string Name, decimal Value, MonthDate StartDate, Frequency Frequency = Frequency.OneTime, MonthDate? EndDate = null) +namespace Predictor.Web.Models; + +public record PaymentItem(string Name, decimal Value, MonthDate StartDate, string Currency, Frequency Frequency = Frequency.OneTime, MonthDate? EndDate = null) { public bool Check(MonthDate month) { @@ -29,6 +31,11 @@ public bool Check(MonthDate month) return false; } + if (string.IsNullOrWhiteSpace(this.Currency)) + { + return false; + } + var calculatedMonth = this.StartDate; var monthInterval = this.Frequency switch { diff --git a/src/Predictor.Web/Models/PredictionRequest.cs b/src/Predictor.Web/Models/PredictionRequest.cs index 3d4c71e..5310094 100644 --- a/src/Predictor.Web/Models/PredictionRequest.cs +++ b/src/Predictor.Web/Models/PredictionRequest.cs @@ -2,7 +2,7 @@ namespace Predictor.Web.Models; -public record PredictionRequest(int PredictionMonths, decimal InitialBudget, MonthDate StartPredictionMonth, PaymentItem[] Incomes, PaymentItem[] Expenses) +public record PredictionRequest(int PredictionMonths, decimal InitialBudget, string OutputCurrency, MonthDate StartPredictionMonth, PaymentItem[] Incomes, PaymentItem[] Expenses) : IRequest { public IEnumerable GetMonthIncomes(MonthDate month) diff --git a/src/Predictor.Web/Predictor.Web.csproj b/src/Predictor.Web/Predictor.Web.csproj index dc2d65e..169b5fe 100644 --- a/src/Predictor.Web/Predictor.Web.csproj +++ b/src/Predictor.Web/Predictor.Web.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Predictor.Web/Program.cs b/src/Predictor.Web/Program.cs index 06cdd5b..6c6383e 100644 --- a/src/Predictor.Web/Program.cs +++ b/src/Predictor.Web/Program.cs @@ -3,6 +3,7 @@ using Microsoft.OpenApi.Models; using Predictor.Web.Calculators; using Predictor.Web.Integrations; +using Predictor.Web.Services; using System.Diagnostics.CodeAnalysis; namespace Predictor.Web; @@ -37,6 +38,8 @@ private static void Main(string[] args) _ = builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly)); _ = builder.Services.AddHealthChecks(); + _ = builder.Services.AddHttpClient(); + _ = builder.Services.AddMemoryCache(); _ = builder.Services.AddSingleton(); _ = builder.Services.AddSingleton(); diff --git a/src/Predictor.Web/Services/CurrencyService.cs b/src/Predictor.Web/Services/CurrencyService.cs new file mode 100644 index 0000000..e80ce0a --- /dev/null +++ b/src/Predictor.Web/Services/CurrencyService.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace Predictor.Web.Services; + +public interface ICurrencyService +{ + Task GetExchangeRateAsync(string fromCurrency, string toCurrency); +} + +public class CurrencyService(HttpClient httpClient) : ICurrencyService +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetExchangeRateAsync(string fromCurrency, string toCurrency) + { + if (fromCurrency.Equals(toCurrency, StringComparison.OrdinalIgnoreCase)) + { + return 1.0M; + } + + string url = $"https://open.er-api.com/v6/latest/{fromCurrency}"; // todo: get from config + + var response = await this._httpClient.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + return 1.0M; + } + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + if (doc.RootElement.TryGetProperty("rates", out var rates) && + rates.TryGetProperty(toCurrency.Trim().ToUpper(), out var rateEl) && + rateEl.TryGetDecimal(out var rate)) + { + return rate; + } + + return 1.0M; + } +} diff --git a/src/Predictor.Web/Validators/PaymentItemValidator.cs b/src/Predictor.Web/Validators/PaymentItemValidator.cs index 9936f13..a6a7896 100644 --- a/src/Predictor.Web/Validators/PaymentItemValidator.cs +++ b/src/Predictor.Web/Validators/PaymentItemValidator.cs @@ -22,5 +22,11 @@ public PaymentItemValidator() _ = this.When(x => x.EndDate != null, () => this.RuleFor(x => x.EndDate!) .NotNull() .SetValidator(new MonthDateValidator())); + + _ = this.RuleFor(x => x.Currency) + .NotEmpty() + .Must(code => ISO._4217.CurrencyCodesResolver.Codes + .Any(c => c.Code.Trim().Equals(code, StringComparison.OrdinalIgnoreCase))) + .WithMessage("Invalid currency code (must be ISO‑4217)."); } } diff --git a/src/Predictor.Web/Validators/PredictionRequestValidator.cs b/src/Predictor.Web/Validators/PredictionRequestValidator.cs index 31bb1ef..743ea70 100644 --- a/src/Predictor.Web/Validators/PredictionRequestValidator.cs +++ b/src/Predictor.Web/Validators/PredictionRequestValidator.cs @@ -28,5 +28,11 @@ public PredictionRequestValidator() _ = this.RuleForEach(x => x.Expenses) .NotNull() .SetValidator(new PaymentItemValidator()); + + _ = this.RuleFor(x => x.OutputCurrency) + .NotEmpty() + .Must(code => ISO._4217.CurrencyCodesResolver.Codes + .Any(c => c.Code.Trim().Equals(code, StringComparison.OrdinalIgnoreCase))) + .WithMessage("Invalid output currency code (must be ISO‑4217)."); } } From bf323ec0617bad1b2a503f7186c6721d72f25896 Mon Sep 17 00:00:00 2001 From: Jadeed Khan Date: Mon, 21 Jul 2025 22:22:44 +1000 Subject: [PATCH 2/4] Fixed existing tests to incorporate currency (income/expense) --- .../BasePredictionTest.cs | 8 +- .../BudgetCalculationTests.cs | 20 ++--- .../BusinessScenariosTests.cs | 74 +++++++++---------- .../PaymentFrequencyTests.cs | 20 ++--- .../SummaryStatisticsTests.cs | 44 +++++------ .../ValidationTests.cs | 20 ++--- 6 files changed, 93 insertions(+), 93 deletions(-) diff --git a/src/Predictor.Tests.Integration/BasePredictionTest.cs b/src/Predictor.Tests.Integration/BasePredictionTest.cs index e8649dd..aa91fbc 100644 --- a/src/Predictor.Tests.Integration/BasePredictionTest.cs +++ b/src/Predictor.Tests.Integration/BasePredictionTest.cs @@ -48,9 +48,9 @@ protected async Task GetResponseStatusCode(PredictionRequest req Incomes: [], Expenses: []); - protected static PaymentItem CreateIncome(string name, decimal value, int month = 1, int year = 2025, - Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), "USD", frequency, endDate); + protected static PaymentItem CreateIncome(string name, decimal value, string currency, int month = 1, int year = 2025, + Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), currency, frequency, endDate); - protected static PaymentItem CreateExpense(string name, decimal value, int month = 1, int year = 2025, - Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), "USD", frequency, endDate); + protected static PaymentItem CreateExpense(string name, decimal value, string currency, int month = 1, int year = 2025, + Frequency frequency = Frequency.OneTime, MonthDate? endDate = null) => new(name, value, new MonthDate(month, year), currency, frequency, endDate); } \ No newline at end of file diff --git a/src/Predictor.Tests.Integration/BudgetCalculationTests.cs b/src/Predictor.Tests.Integration/BudgetCalculationTests.cs index fa9345e..61016af 100644 --- a/src/Predictor.Tests.Integration/BudgetCalculationTests.cs +++ b/src/Predictor.Tests.Integration/BudgetCalculationTests.cs @@ -20,8 +20,8 @@ public async Task Prediction_ShouldAccumulateBudgetCorrectly( // Arrange var request = CreateBasicRequest(months, initialBudget) with { - Incomes = [CreateIncome("Income", income, frequency: Frequency.Monthly)], - Expenses = [CreateExpense("Expense", expense, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("Income", income, "USD", frequency: Frequency.Monthly)], + Expenses = [CreateExpense("Expense", expense, "USD", frequency: Frequency.Monthly)] }; // Act @@ -47,12 +47,12 @@ public async Task Prediction_WithMultipleSources_ShouldSumCorrectly() var request = CreateBasicRequest(1) with { Incomes = [ - CreateIncome("Salary", 3000m), - CreateIncome("Bonus", 2000m) + CreateIncome("Salary", 3000m, "USD"), + CreateIncome("Bonus", 2000m, "USD") ], Expenses = [ - CreateExpense("Rent", 1200m), - CreateExpense("Food", 800m) + CreateExpense("Rent", 1200m, "USD"), + CreateExpense("Food", 800m, "USD") ] }; @@ -71,7 +71,7 @@ public async Task Prediction_WithOnlyIncomes_ShouldIncreaseBalance() // Arrange var request = CreateBasicRequest(2, 1000m) with { - Incomes = [CreateIncome("Salary", 2000m, frequency: Frequency.Monthly)], + Incomes = [CreateIncome("Salary", 2000m, "USD", frequency: Frequency.Monthly)], Expenses = [] }; @@ -92,7 +92,7 @@ public async Task Prediction_WithOnlyExpenses_ShouldDecreaseBalance() var request = CreateBasicRequest(2, 5000m) with { Incomes = [], - Expenses = [CreateExpense("Rent", 1500m, frequency: Frequency.Monthly)] + Expenses = [CreateExpense("Rent", 1500m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -111,8 +111,8 @@ public async Task Prediction_WithZeroInitialBudget_ShouldCalculateCorrectly() // Arrange var request = CreateBasicRequest(1, 0m) with { - Incomes = [CreateIncome("Income", 1000m)], - Expenses = [CreateExpense("Expense", 300m)] + Incomes = [CreateIncome("Income", 1000m, "USD")], + Expenses = [CreateExpense("Expense", 300m, "USD")] }; // Act diff --git a/src/Predictor.Tests.Integration/BusinessScenariosTests.cs b/src/Predictor.Tests.Integration/BusinessScenariosTests.cs index c708b57..2323bdd 100644 --- a/src/Predictor.Tests.Integration/BusinessScenariosTests.cs +++ b/src/Predictor.Tests.Integration/BusinessScenariosTests.cs @@ -12,9 +12,9 @@ public async Task Prediction_SavingForHouseDownPayment_ShouldCalculateTimeframe( var targetAmount = 50_000m; var request = CreateBasicRequest(40, 15_000m) with { - Incomes = [CreateIncome("Monthly Salary", 7_000m, frequency: Frequency.Monthly)], + Incomes = [CreateIncome("Monthly Salary", 7_000m, "USD", frequency: Frequency.Monthly)], Expenses = [ - CreateExpense("Living Expenses", 4_000m, frequency: Frequency.Monthly), CreateExpense("Savings Goal", 2_000m, frequency: Frequency.Monthly) ] + CreateExpense("Living Expenses", 4_000m, "USD", frequency: Frequency.Monthly), CreateExpense("Savings Goal", 2_000m, "USD", frequency: Frequency.Monthly) ] }; // Act @@ -40,10 +40,10 @@ public async Task Prediction_PayingOffStudentLoan_ShouldShowDebtFreedom() // Arrange - Student loan paid off in 18 months var request = CreateBasicRequest(24, 2_000m) with { - Incomes = [CreateIncome("Job Income", 4_500m, frequency: Frequency.Monthly)], + Incomes = [CreateIncome("Job Income", 4_500m, "USD", frequency: Frequency.Monthly)], Expenses = [ - CreateExpense("Living Costs", 3_000m, frequency: Frequency.Monthly), - CreateExpense("Student Loan", 800m, frequency: Frequency.Monthly, + CreateExpense("Living Costs", 3_000m, "USD", frequency: Frequency.Monthly), + CreateExpense("Student Loan", 800m, "USD", frequency: Frequency.Monthly, endDate: new MonthDate(6, 2026)) ] }; @@ -68,13 +68,13 @@ public async Task Prediction_RetirementPlanning_ShouldShowLongTermGrowth() var request = CreateBasicRequest(60, 50_000m) with { Incomes = [ - CreateIncome("Salary", 8_000m, frequency: Frequency.Monthly), - CreateIncome("Annual Bonus", 10_000m, frequency: Frequency.Annually) + CreateIncome("Salary", 8_000m, "USD", frequency: Frequency.Monthly), + CreateIncome("Annual Bonus", 10_000m, "USD", frequency: Frequency.Annually) ], Expenses = [ - CreateExpense("Living Expenses", 5_500m, frequency: Frequency.Monthly), - CreateExpense("401k Contribution", 1_200m, frequency: Frequency.Monthly), - CreateExpense("IRA Contribution", 500m, frequency: Frequency.Monthly) + CreateExpense("Living Expenses", 5_500m, "USD", frequency: Frequency.Monthly), + CreateExpense("401k Contribution", 1_200m, "USD", frequency: Frequency.Monthly), + CreateExpense("IRA Contribution", 500m, "USD", frequency: Frequency.Monthly) ] }; @@ -101,10 +101,10 @@ public async Task Prediction_SeasonalBusinessIncome_ShouldHandleIrregularity() var request = CreateBasicRequest(12, 15_000m) with { Incomes = [ - CreateIncome("Summer Revenue", 20_000m, month: 6, frequency: Frequency.OneTime), CreateIncome("Summer Revenue", 25_000m, month: 7, frequency: Frequency.OneTime), CreateIncome("Summer Revenue", 18_000m, month: 8, frequency: Frequency.OneTime), CreateIncome("Holiday Revenue", 12_000m, month: 11, frequency: Frequency.OneTime), CreateIncome("Holiday Revenue", 15_000m, month: 12, frequency: Frequency.OneTime), CreateIncome("Base Income", 3_000m, frequency: Frequency.Monthly) ], + CreateIncome("Summer Revenue", 20_000m, "USD", month: 6, frequency: Frequency.OneTime), CreateIncome("Summer Revenue", 25_000m, "USD", month: 7, frequency: Frequency.OneTime), CreateIncome("Summer Revenue", 18_000m, "USD", month: 8, frequency: Frequency.OneTime), CreateIncome("Holiday Revenue", 12_000m, "USD", month: 11, frequency: Frequency.OneTime), CreateIncome("Holiday Revenue", 15_000m, "USD", month: 12, frequency: Frequency.OneTime), CreateIncome("Base Income", 3_000m, "USD", frequency: Frequency.Monthly) ], Expenses = [ - CreateExpense("Fixed Costs", 4_000m, frequency: Frequency.Monthly), - CreateExpense("Summer Marketing", 2_000m, month: 5, frequency: Frequency.OneTime), CreateExpense("Holiday Inventory", 3_000m, month: 10, frequency: Frequency.OneTime) ] + CreateExpense("Fixed Costs", 4_000m, "USD", frequency: Frequency.Monthly), + CreateExpense("Summer Marketing", 2_000m, "USD", month: 5, frequency: Frequency.OneTime), CreateExpense("Holiday Inventory", 3_000m, "USD", month: 10, frequency: Frequency.OneTime) ] }; // Act @@ -126,13 +126,13 @@ public async Task Prediction_FreelancerWithIrregularWork_ShouldManageCashFlow() var request = CreateBasicRequest(12, 8_000m) with { Incomes = [ - CreateIncome("Project A Payment", 8_000m, month: 2, frequency: Frequency.OneTime), - CreateIncome("Project B Payment", 12_000m, month: 5, frequency: Frequency.OneTime), - CreateIncome("Project C Payment", 6_000m, month: 8, frequency: Frequency.OneTime), - CreateIncome("Project D Payment", 15_000m, month: 11, frequency: Frequency.OneTime), - CreateIncome("Retainer Client", 2_500m, frequency: Frequency.Monthly) ], + CreateIncome("Project A Payment", 8_000m, "USD", month: 2, frequency: Frequency.OneTime), + CreateIncome("Project B Payment", 12_000m, "USD", month: 5, frequency: Frequency.OneTime), + CreateIncome("Project C Payment", 6_000m, "USD", month: 8, frequency: Frequency.OneTime), + CreateIncome("Project D Payment", 15_000m, "USD", month: 11, frequency: Frequency.OneTime), + CreateIncome("Retainer Client", 2_500m, "USD", frequency: Frequency.Monthly) ], Expenses = [ - CreateExpense("Living Expenses", 3_000m, frequency: Frequency.Monthly), CreateExpense("Business Expenses", 500m, frequency: Frequency.Monthly), CreateExpense("Quarterly Taxes", 2_000m, frequency: Frequency.Quarterly) ] + CreateExpense("Living Expenses", 3_000m, "USD", frequency: Frequency.Monthly), CreateExpense("Business Expenses", 500m, "USD", frequency: Frequency.Monthly), CreateExpense("Quarterly Taxes", 2_000m, "USD", frequency: Frequency.Quarterly) ] }; // Act @@ -156,18 +156,18 @@ public async Task Prediction_FamilyBudgetWithChildExpenses_ShouldAccountForGrowi var request = CreateBasicRequest(36, 15_000m) with { Incomes = [ - CreateIncome("Parent 1 Salary", 6_500m, frequency: Frequency.Monthly), - CreateIncome("Parent 2 Salary", 4_800m, frequency: Frequency.Monthly), - CreateIncome("Tax Refund", 3_500m, month: 4, frequency: Frequency.Annually) ], + CreateIncome("Parent 1 Salary", 6_500m, "USD", frequency: Frequency.Monthly), + CreateIncome("Parent 2 Salary", 4_800m, "USD", frequency: Frequency.Monthly), + CreateIncome("Tax Refund", 3_500m, "USD", month: 4, frequency: Frequency.Annually) ], Expenses = [ - CreateExpense("Mortgage", 2_800m, frequency: Frequency.Monthly), - CreateExpense("Utilities & Home", 600m, frequency: Frequency.Monthly), - CreateExpense("Food & Groceries", 1_200m, frequency: Frequency.Monthly), - CreateExpense("Childcare", 1_800m, frequency: Frequency.Monthly, - endDate: new MonthDate(6, 2027)), CreateExpense("School Supplies", 800m, month: 8, frequency: Frequency.Annually), - CreateExpense("Medical/Dental", 400m, frequency: Frequency.Quarterly), - CreateExpense("College Savings", 1_000m, frequency: Frequency.Monthly), - CreateExpense("Activities/Sports", 300m, month: 12, frequency: Frequency.Monthly) ] + CreateExpense("Mortgage", 2_800m, "USD",frequency: Frequency.Monthly), + CreateExpense("Utilities & Home", 600m, "USD", frequency: Frequency.Monthly), + CreateExpense("Food & Groceries", 1_200m, "USD", frequency: Frequency.Monthly), + CreateExpense("Childcare", 1_800m, "USD",frequency: Frequency.Monthly, + endDate: new MonthDate(6, 2027)), CreateExpense("School Supplies", 800m, "USD", month: 8, frequency: Frequency.Annually), + CreateExpense("Medical/Dental", 400m, "USD", frequency: Frequency.Quarterly), + CreateExpense("College Savings", 1_000m, "USD", frequency: Frequency.Monthly), + CreateExpense("Activities/Sports", 300m, "USD", month: 12, frequency: Frequency.Monthly) ] }; // Act @@ -198,17 +198,17 @@ public async Task Prediction_StartupFounderScenario_ShouldSurviveInitialLosses() var request = CreateBasicRequest(18, 50_000m) with { Incomes = [ - CreateIncome("First Revenue", 3_000m, month: 7, frequency: Frequency.Monthly, + CreateIncome("First Revenue", 3_000m, "USD", month: 7, frequency: Frequency.Monthly, endDate: new MonthDate(12, 2025)), - CreateIncome("Growing Revenue", 10_000m, month: 1, year: 2026, frequency: Frequency.Monthly) + CreateIncome("Growing Revenue", 10_000m, "USD", month: 1, year: 2026, frequency: Frequency.Monthly) ], Expenses = [ - CreateExpense("Development Costs", 3_000m, frequency: Frequency.Monthly, + CreateExpense("Development Costs", 3_000m, "USD", frequency: Frequency.Monthly, endDate: new MonthDate(6, 2025)), - CreateExpense("Office Setup", 8_000m, month: 1, frequency: Frequency.OneTime), - CreateExpense("Legal/Registration", 3_000m, month: 2, frequency: Frequency.OneTime), - CreateExpense("Minimal Living", 2_000m, frequency: Frequency.Monthly), - CreateExpense("Business Operations", 1_000m, month: 7, frequency: Frequency.Monthly) + CreateExpense("Office Setup", 8_000m, "USD", month: 1, frequency: Frequency.OneTime), + CreateExpense("Legal/Registration", 3_000m, "USD", month: 2, frequency: Frequency.OneTime), + CreateExpense("Minimal Living", 2_000m, "USD", frequency: Frequency.Monthly), + CreateExpense("Business Operations", 1_000m, "USD", month: 7, frequency: Frequency.Monthly) ] }; diff --git a/src/Predictor.Tests.Integration/PaymentFrequencyTests.cs b/src/Predictor.Tests.Integration/PaymentFrequencyTests.cs index 55061be..2d60582 100644 --- a/src/Predictor.Tests.Integration/PaymentFrequencyTests.cs +++ b/src/Predictor.Tests.Integration/PaymentFrequencyTests.cs @@ -20,7 +20,7 @@ public async Task Prediction_WithRecurringFrequency_ShouldOccurAtCorrectInterval // Arrange var request = CreateBasicRequest(totalMonths) with { - Incomes = [CreateIncome("Recurring Income", 1000m, frequency: frequency)] + Incomes = [CreateIncome("Recurring Income", 1000m, "USD", frequency: frequency)] }; // Act @@ -51,7 +51,7 @@ public async Task Prediction_WithOneTimeFrequency_ShouldOccurOnlyOnce( // Arrange var request = CreateBasicRequest(totalMonths) with { - Incomes = [CreateIncome("One-time Bonus", 1000m, targetMonth, frequency: Frequency.OneTime)] + Incomes = [CreateIncome("One-time Bonus", 1000m, "USD", targetMonth, frequency: Frequency.OneTime)] }; // Act @@ -75,7 +75,7 @@ public async Task Prediction_WithEndDate_ShouldStopAfterEndDate() var endDate = new MonthDate(3, 2025); var request = CreateBasicRequest(6) with { - Incomes = [CreateIncome("Contract", 1000m, frequency: Frequency.Monthly, endDate: endDate)] + Incomes = [CreateIncome("Contract", 1000m, "USD", frequency: Frequency.Monthly, endDate: endDate)] }; // Act @@ -95,7 +95,7 @@ public async Task Prediction_WithEndDateBeforeStart_ShouldNotOccur() var endDate = new MonthDate(12, 2024); var request = CreateBasicRequest(3) with { - Incomes = [CreateIncome("Expired Contract", 1000m, startDate.Month, startDate.Year, Frequency.Monthly, endDate)] + Incomes = [CreateIncome("Expired Contract", 1000m, "USD", startDate.Month, startDate.Year, Frequency.Monthly, endDate)] }; // Act @@ -113,7 +113,7 @@ public async Task Prediction_WithLateStartDate_ShouldStartFromCorrectMonth() // Arrange var request = CreateBasicRequest(5) with { - Incomes = [CreateIncome("Late Start", 1000m, month: 3, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("Late Start", 1000m, "USD", month: 3, frequency: Frequency.Monthly)] }; // Act @@ -131,7 +131,7 @@ public async Task Prediction_WithQuarterlyStartingInMiddle_ShouldCalculateCorrec // Arrange - Start quarterly payment in month 2, should occur in months 2, 5, 8 var request = CreateBasicRequest(9) with { - Incomes = [CreateIncome("Quarterly Mid-Start", 3000m, month: 2, frequency: Frequency.Quarterly)] + Incomes = [CreateIncome("Quarterly Mid-Start", 3000m, "USD", month: 2, frequency: Frequency.Quarterly)] }; // Act @@ -157,10 +157,10 @@ public async Task Prediction_WithMixedFrequencies_ShouldCalculateAllCorrectly() var request = CreateBasicRequest(12) with { Incomes = [ - CreateIncome("Monthly Salary", 3000m, frequency: Frequency.Monthly), - CreateIncome("Quarterly Bonus", 2000m, frequency: Frequency.Quarterly), - CreateIncome("Annual Bonus", 10000m, frequency: Frequency.Annually), - CreateIncome("One-time Gift", 5000m, month: 6, frequency: Frequency.OneTime) + CreateIncome("Monthly Salary", 3000m, "USD", frequency: Frequency.Monthly), + CreateIncome("Quarterly Bonus", 2000m, "USD", frequency: Frequency.Quarterly), + CreateIncome("Annual Bonus", 10000m, "USD", frequency: Frequency.Annually), + CreateIncome("One-time Gift", 5000m, "USD", month: 6, frequency: Frequency.OneTime) ] }; diff --git a/src/Predictor.Tests.Integration/SummaryStatisticsTests.cs b/src/Predictor.Tests.Integration/SummaryStatisticsTests.cs index ec1003a..7e09c15 100644 --- a/src/Predictor.Tests.Integration/SummaryStatisticsTests.cs +++ b/src/Predictor.Tests.Integration/SummaryStatisticsTests.cs @@ -12,12 +12,12 @@ public async Task Prediction_ShouldCalculateCorrectSummaryStatistics() var request = CreateBasicRequest(3, 2000m) with { Incomes = [ - CreateIncome("Regular Salary", 3000m, frequency: Frequency.Monthly), - CreateIncome("Big Bonus", 5000m, month: 2, frequency: Frequency.OneTime) + CreateIncome("Regular Salary", 3000m, "USD", frequency: Frequency.Monthly), + CreateIncome("Big Bonus", 5000m, "USD", month: 2, frequency: Frequency.OneTime) ], Expenses = [ - CreateExpense("Big Purchase", 4000m, frequency: Frequency.OneTime), - CreateExpense("Regular Expense", 1000m, frequency: Frequency.Monthly) + CreateExpense("Big Purchase", 4000m, "USD", frequency: Frequency.OneTime), + CreateExpense("Regular Expense", 1000m, "USD", frequency: Frequency.Monthly) ] }; @@ -39,8 +39,8 @@ public async Task Prediction_WithConstantBalance_ShouldHaveSameLowestAndHighest( // Arrange var request = CreateBasicRequest(3) with { - Incomes = [CreateIncome("Income", 1000m, frequency: Frequency.Monthly)], - Expenses = [CreateExpense("Expense", 1000m, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("Income", 1000m, "USD", frequency: Frequency.Monthly)], + Expenses = [CreateExpense("Expense", 1000m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -59,8 +59,8 @@ public async Task Prediction_WithIncreasingBalance_ShouldHaveCorrectMinMax() // Arrange var request = CreateBasicRequest(4, 1000m) with { - Incomes = [CreateIncome("Growing Income", 2000m, frequency: Frequency.Monthly)], - Expenses = [CreateExpense("Fixed Expense", 1000m, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("Growing Income", 2000m, "USD", frequency: Frequency.Monthly)], + Expenses = [CreateExpense("Fixed Expense", 1000m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -80,7 +80,7 @@ public async Task Prediction_WithDecreasingBalance_ShouldHaveCorrectMinMax() var request = CreateBasicRequest(3, 5000m) with { Incomes = [], - Expenses = [CreateExpense("Heavy Expense", 1500m, frequency: Frequency.Monthly)] + Expenses = [CreateExpense("Heavy Expense", 1500m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -101,13 +101,13 @@ public async Task Prediction_WithFluctuatingBalance_ShouldFindCorrectExtremes() var request = CreateBasicRequest(4) with { Incomes = [ - CreateIncome("Regular Income", 1000m, frequency: Frequency.Monthly), - CreateIncome("Bonus Month 1", 1000m, month: 1, frequency: Frequency.OneTime), - CreateIncome("Big Bonus Month 3", 3000m, month: 3, frequency: Frequency.OneTime) + CreateIncome("Regular Income", 1000m, "USD", frequency: Frequency.Monthly), + CreateIncome("Bonus Month 1", 1000m, "USD", month: 1, frequency: Frequency.OneTime), + CreateIncome("Big Bonus Month 3", 3000m, "USD", month: 3, frequency: Frequency.OneTime) ], Expenses = [ - CreateExpense("Big Expense Month 2", 4000m, month: 2, frequency: Frequency.OneTime), - CreateExpense("Regular Expense", 100m, frequency: Frequency.Monthly) ] + CreateExpense("Big Expense Month 2", 4000m, "USD", month: 2, frequency: Frequency.OneTime), + CreateExpense("Regular Expense", 100m, "USD", frequency: Frequency.Monthly) ] }; // Act @@ -127,8 +127,8 @@ public async Task Prediction_WithOnlyOneMonth_ShouldHaveSameStartAndEnd() // Arrange var request = CreateBasicRequest(1, 5000m) with { - Incomes = [CreateIncome("Single Income", 2000m)], - Expenses = [CreateExpense("Single Expense", 800m)] + Incomes = [CreateIncome("Single Income", 2000m, "USD")], + Expenses = [CreateExpense("Single Expense", 800m, "USD")] }; // Act @@ -169,8 +169,8 @@ public async Task Prediction_WithMultipleMonthsHavingSameBalance_ShouldReturnFir // Arrange - All months will have the same balance var request = CreateBasicRequest(5) with { - Incomes = [CreateIncome("Steady Income", 1500m, frequency: Frequency.Monthly)], - Expenses = [CreateExpense("Steady Expense", 1500m, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("Steady Income", 1500m, "USD", frequency: Frequency.Monthly)], + Expenses = [CreateExpense("Steady Expense", 1500m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -189,8 +189,8 @@ public async Task Prediction_WithLargeNumbers_ShouldCalculateCorrectly() // Arrange var request = CreateBasicRequest(2, 1_000_000m) with { - Incomes = [CreateIncome("High Income", 500_000m, frequency: Frequency.Monthly)], - Expenses = [CreateExpense("High Expense", 300_000m, frequency: Frequency.Monthly)] + Incomes = [CreateIncome("High Income", 500_000m, "USD", frequency: Frequency.Monthly)], + Expenses = [CreateExpense("High Expense", 300_000m, "USD", frequency: Frequency.Monthly)] }; // Act @@ -211,8 +211,8 @@ public async Task Prediction_WithDecimalPrecision_ShouldMaintainAccuracy() // Arrange var request = CreateBasicRequest(1, 100.50m) with { - Incomes = [CreateIncome("Precise Income", 1234.67m)], - Expenses = [CreateExpense("Precise Expense", 567.89m)] + Incomes = [CreateIncome("Precise Income", 1234.67m, "USD")], + Expenses = [CreateExpense("Precise Expense", 567.89m, "USD")] }; // Act diff --git a/src/Predictor.Tests.Integration/ValidationTests.cs b/src/Predictor.Tests.Integration/ValidationTests.cs index 18f843f..e1881b4 100644 --- a/src/Predictor.Tests.Integration/ValidationTests.cs +++ b/src/Predictor.Tests.Integration/ValidationTests.cs @@ -80,7 +80,7 @@ public async Task Prediction_WithIncomeItemName_ShouldValidateCorrectly(string n // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome(name, 1000m)] + Incomes = [CreateIncome(name, 1000m, "USD")] }; // Act & Assert @@ -96,7 +96,7 @@ public async Task Prediction_WithExpenseItemName_ShouldValidateCorrectly(string // Arrange var request = CreateBasicRequest() with { - Expenses = [CreateExpense(name, 1000m)] + Expenses = [CreateExpense(name, 1000m, "USD")] }; // Act & Assert @@ -114,7 +114,7 @@ public async Task Prediction_WithIncomeItemValue_ShouldValidateCorrectly(decimal // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome("Valid Name", value)] + Incomes = [CreateIncome("Valid Name", value, "USD")] }; // Act & Assert @@ -131,7 +131,7 @@ public async Task Prediction_WithExpenseItemValue_ShouldValidateCorrectly(decima // Arrange var request = CreateBasicRequest() with { - Expenses = [CreateExpense("Valid Name", value)] + Expenses = [CreateExpense("Valid Name", value, "USD")] }; // Act & Assert @@ -148,7 +148,7 @@ public async Task Prediction_WithPaymentItemStartDate_ShouldValidateCorrectly(in // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome("Valid Name", 1000m, month)] + Incomes = [CreateIncome("Valid Name", 1000m, "USD", month)] }; // Act & Assert @@ -165,7 +165,7 @@ public async Task Prediction_WithPaymentItemStartYear_ShouldValidateCorrectly(in // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome("Valid Name", 1000m, year: year)] + Incomes = [CreateIncome("Valid Name", 1000m, "USD", year: year)] }; // Act & Assert @@ -179,7 +179,7 @@ public async Task Prediction_WithValidEndDate_ShouldReturnOk() // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome("Contract", 1000m, endDate: new MonthDate(12, 2025))] + Incomes = [CreateIncome("Contract", 1000m, "USD", endDate: new MonthDate(12, 2025))] }; // Act & Assert @@ -194,7 +194,7 @@ public async Task Prediction_WithInvalidEndDate_ShouldReturnBadRequest(int month // Arrange var request = CreateBasicRequest() with { - Incomes = [CreateIncome("Contract", 1000m, endDate: new MonthDate(month, 2025))] + Incomes = [CreateIncome("Contract", 1000m, "USD", endDate: new MonthDate(month, 2025))] }; // Act & Assert @@ -220,7 +220,7 @@ public async Task Prediction_WithVeryLongPaymentName_ShouldReturnBadRequest() var longName = new string('a', 101); var request = CreateBasicRequest() with { - Incomes = [CreateIncome(longName, 1000m)] + Incomes = [CreateIncome(longName, 1000m, "USD")] }; // Act & Assert @@ -235,7 +235,7 @@ public async Task Prediction_WithMaxValidPaymentName_ShouldReturnOk() var maxName = new string('a', 100); var request = CreateBasicRequest() with { - Incomes = [CreateIncome(maxName, 1000m)] + Incomes = [CreateIncome(maxName, 1000m, "USD")] }; // Act & Assert From 7994a6dfa8f6303ae1d26ec39ce8810c376c392b Mon Sep 17 00:00:00 2001 From: Jadeed Khan Date: Mon, 21 Jul 2025 23:35:43 +1000 Subject: [PATCH 3/4] Added couple of tests --- .../BudgetCalculationTests.cs | 39 +++++++++++++++++++ .../ValidationTests.cs | 22 +++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/Predictor.Tests.Integration/BudgetCalculationTests.cs b/src/Predictor.Tests.Integration/BudgetCalculationTests.cs index 61016af..193a5c6 100644 --- a/src/Predictor.Tests.Integration/BudgetCalculationTests.cs +++ b/src/Predictor.Tests.Integration/BudgetCalculationTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Predictor.Web.Models; +using System.Net; namespace Predictor.Tests.Integration; @@ -122,4 +123,42 @@ public async Task Prediction_WithZeroInitialBudget_ShouldCalculateCorrectly() _ = result.Months[0].Balance.Should().Be(700m); _ = result.Months[0].BudgetAfter.Should().Be(700m); } + + [Test] + public async Task Prediction_WithDifferentInputAndOutputCurrencies_ShouldReturnOk() + { + // Arrange + var request = CreateBasicRequest() with + { + OutputCurrency = "USD", + Incomes = [CreateIncome("Salary", 1000m, "AUD")], // different from output + Expenses = [CreateExpense("Rent", 500m, "CAD")] + }; + + // Act + var status = await this.GetResponseStatusCode(request); + + // Assert + _ = status.Should().Be(HttpStatusCode.OK); + } + [Test] + public async Task Prediction_WithCurrencyConversion_ShouldConvertCorrectly() + { + // Arrange + var request = CreateBasicRequest() with + { + OutputCurrency = "AUD", + Incomes = [CreateIncome("Salary", 1000m, "USD")] + }; + + // Act + var result = await this.GetPredictionResult(request); + + // Assert + result.Months.Should().NotBeNullOrEmpty(); + + var incomeInUsd = result.Months[0].Income; + + incomeInUsd.Should().BeGreaterThan(1000m); // Conversion from USD to AUD should increase value + } } \ No newline at end of file diff --git a/src/Predictor.Tests.Integration/ValidationTests.cs b/src/Predictor.Tests.Integration/ValidationTests.cs index e1881b4..7a3d1f1 100644 --- a/src/Predictor.Tests.Integration/ValidationTests.cs +++ b/src/Predictor.Tests.Integration/ValidationTests.cs @@ -242,4 +242,26 @@ public async Task Prediction_WithMaxValidPaymentName_ShouldReturnOk() var status = await this.GetResponseStatusCode(request); _ = status.Should().Be(HttpStatusCode.OK); } + + [TestCase("", HttpStatusCode.BadRequest)] + [TestCase(" ", HttpStatusCode.BadRequest)] + [TestCase("XYZ", HttpStatusCode.BadRequest)] // Not a real ISO code + [TestCase("usd", HttpStatusCode.OK)] // Lowercase, still valid + [TestCase("USD", HttpStatusCode.OK)] // Valid + [TestCase("EUR", HttpStatusCode.OK)] // Valid + [TestCase("GBP", HttpStatusCode.OK)] // Valid + [TestCase("US D", HttpStatusCode.BadRequest)] // Invalid with space + public async Task Prediction_WithCurrencyCode_ShouldValidateCorrectly(string currency, HttpStatusCode expectedStatus) + { + // Arrange + var request = CreateBasicRequest() with + { + Incomes = [CreateIncome("Salary", 1000m, currency)] + }; + + // Act & Assert + var status = await this.GetResponseStatusCode(request); + _ = status.Should().Be(expectedStatus); + } + } \ No newline at end of file From 2795509ef75cfa12792a8b9e52df98e424d2636b Mon Sep 17 00:00:00 2001 From: Jadeed Khan Date: Mon, 21 Jul 2025 23:38:24 +1000 Subject: [PATCH 4/4] fixed warning --- src/Predictor.Tests.Integration/ValidationTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Predictor.Tests.Integration/ValidationTests.cs b/src/Predictor.Tests.Integration/ValidationTests.cs index 7a3d1f1..68d84dc 100644 --- a/src/Predictor.Tests.Integration/ValidationTests.cs +++ b/src/Predictor.Tests.Integration/ValidationTests.cs @@ -263,5 +263,4 @@ public async Task Prediction_WithCurrencyCode_ShouldValidateCorrectly(string cur var status = await this.GetResponseStatusCode(request); _ = status.Should().Be(expectedStatus); } - } \ No newline at end of file